1
0
forked from wrenn/wrenn

feat: add audit logging for all admin actions and admin audit page

Log every admin-panel action (user activate/deactivate, team BYOC toggle,
team delete, template delete, build create/cancel) to the audit_logs table
under PlatformTeamID with scope "admin".

Add GET /v1/admin/audit-logs endpoint and /admin/audit frontend page with
infinite scroll and hierarchical filters. Expose audit.Entry + Log() for
cloud repo extensibility.

Fix seed_platform_team down-migration FK violation by deleting dependent
rows before the team row.
This commit is contained in:
2026-04-21 15:41:45 +06:00
parent edec170652
commit 7fd801c1eb
10 changed files with 917 additions and 51 deletions

View File

@ -35,64 +35,38 @@ type auditLogResponse struct {
CreatedAt string `json:"created_at"`
}
// List handles GET /v1/audit-logs.
// Query params:
// - before: RFC3339 timestamp cursor (exclusive); omit to start from latest
// - limit: page size, default 50, max 200
// - resource_type: filter by resource type (sandbox, snapshot, team, api_key, member, host)
// - action: filter by action verb
//
// Members see only team-scoped events; admins/owners see all.
func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
// parseAuditParams extracts common query parameters for audit log listing.
func parseAuditParams(r *http.Request) (before time.Time, beforeID pgtype.UUID, limit int, err error) {
limit = 50
// Parse ?before cursor.
var before time.Time
if s := r.URL.Query().Get("before"); s != "" {
var err error
before, err = time.Parse(time.RFC3339, s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "before must be an RFC3339 timestamp")
return
}
}
// Parse ?limit.
limit := 50
if s := r.URL.Query().Get("limit"); s != "" {
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "invalid_request", "limit must be a positive integer")
n, parseErr := strconv.Atoi(s)
if parseErr != nil || n < 1 {
err = parseErr
return
}
limit = n
}
// Parse ?before_id cursor (UUID).
var beforeID pgtype.UUID
if s := r.URL.Query().Get("before_id"); s != "" {
parsed, err := id.ParseAuditLogID(s)
beforeID, err = id.ParseAuditLogID(s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "before_id must be a valid audit log ID")
return
}
beforeID = parsed
}
entries, err := h.svc.List(r.Context(), service.AuditListParams{
TeamID: ac.TeamID,
AdminScoped: ac.Role == "owner" || ac.Role == "admin",
ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]),
Actions: parseMultiParam(r.URL.Query()["action"]),
Before: before,
BeforeID: beforeID,
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
return
}
return
}
// writeAuditResponse serializes audit entries into a paginated JSON response.
func writeAuditResponse(w http.ResponseWriter, entries []service.AuditEntry) {
items := make([]auditLogResponse, len(entries))
for i, e := range entries {
items[i] = auditLogResponse{
@ -120,6 +94,67 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// List handles GET /v1/audit-logs.
// Query params:
// - before: RFC3339 timestamp cursor (exclusive); omit to start from latest
// - limit: page size, default 50, max 200
// - resource_type: filter by resource type (sandbox, snapshot, team, api_key, member, host)
// - action: filter by action verb
//
// Members see only team-scoped events; admins/owners see all.
func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
before, beforeID, limit, err := parseAuditParams(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
return
}
entries, err := h.svc.List(r.Context(), service.AuditListParams{
TeamID: ac.TeamID,
AdminScoped: ac.Role == "owner" || ac.Role == "admin",
ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]),
Actions: parseMultiParam(r.URL.Query()["action"]),
Before: before,
BeforeID: beforeID,
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
return
}
writeAuditResponse(w, entries)
}
// AdminList handles GET /v1/admin/audit-logs.
// Returns audit logs for the platform team (team 0) with both team and admin scopes.
// Uses the same query params as List.
func (h *auditHandler) AdminList(w http.ResponseWriter, r *http.Request) {
before, beforeID, limit, err := parseAuditParams(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
return
}
entries, err := h.svc.List(r.Context(), service.AuditListParams{
TeamID: id.PlatformTeamID,
AdminScoped: true,
ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]),
Actions: parseMultiParam(r.URL.Query()["action"]),
Before: before,
BeforeID: beforeID,
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
return
}
writeAuditResponse(w, entries)
}
// parseMultiParam flattens repeated params and comma-separated values into a
// single deduplicated slice. Empty strings are dropped. Returns nil (no filter)
// when no values are present.

View File

@ -13,6 +13,8 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/internal/layout"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -22,13 +24,14 @@ import (
)
type buildHandler struct {
svc *service.BuildService
db *db.Queries
pool *lifecycle.HostClientPool
svc *service.BuildService
db *db.Queries
pool *lifecycle.HostClientPool
audit *audit.AuditLogger
}
func newBuildHandler(svc *service.BuildService, db *db.Queries, pool *lifecycle.HostClientPool) *buildHandler {
return &buildHandler{svc: svc, db: db, pool: pool}
func newBuildHandler(svc *service.BuildService, db *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger) *buildHandler {
return &buildHandler{svc: svc, db: db, pool: pool, audit: al}
}
type createBuildRequest struct {
@ -187,6 +190,8 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
ac := auth.MustFromContext(r.Context())
h.audit.LogBuildCreate(r.Context(), ac, build.ID, req.Name)
writeJSON(w, http.StatusCreated, buildToResponse(build))
}
@ -305,6 +310,8 @@ func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
return
}
ac := auth.MustFromContext(r.Context())
h.audit.LogTemplateDelete(r.Context(), ac, name)
w.WriteHeader(http.StatusNoContent)
}
@ -323,5 +330,7 @@ func (h *buildHandler) Cancel(w http.ResponseWriter, r *http.Request) {
return
}
ac := auth.MustFromContext(r.Context())
h.audit.LogBuildCancel(r.Context(), ac, buildID)
w.WriteHeader(http.StatusNoContent)
}

View File

@ -392,6 +392,7 @@ func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) {
// SetBYOC handles PUT /v1/admin/teams/{id}/byoc (admin only).
// Enables or disables the BYOC feature flag for a team.
func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
teamIDStr := chi.URLParam(r, "id")
teamID, err := id.ParseTeamID(teamIDStr)
@ -414,6 +415,7 @@ func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogTeamSetBYOC(r.Context(), ac, teamID, req.Enabled)
w.WriteHeader(http.StatusNoContent)
}
@ -484,6 +486,7 @@ func (h *teamHandler) AdminListTeams(w http.ResponseWriter, r *http.Request) {
// AdminDeleteTeam handles DELETE /v1/admin/teams/{id}
// Soft-deletes a team and destroys all its active sandboxes.
func (h *teamHandler) AdminDeleteTeam(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
teamIDStr := chi.URLParam(r, "id")
teamID, err := id.ParseTeamID(teamIDStr)
@ -498,5 +501,6 @@ func (h *teamHandler) AdminDeleteTeam(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogTeamDelete(r.Context(), ac, teamID)
w.WriteHeader(http.StatusNoContent)
}

View File

@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -16,12 +17,13 @@ import (
)
type usersHandler struct {
db *db.Queries
svc *service.UserService
db *db.Queries
svc *service.UserService
audit *audit.AuditLogger
}
func newUsersHandler(db *db.Queries, svc *service.UserService) *usersHandler {
return &usersHandler{db: db, svc: svc}
func newUsersHandler(db *db.Queries, svc *service.UserService, al *audit.AuditLogger) *usersHandler {
return &usersHandler{db: db, svc: svc, audit: al}
}
// Search handles GET /v1/users/search?email=<prefix>
@ -140,11 +142,23 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
newStatus = "disabled"
}
// Look up user email for audit log before changing status.
user, err := h.db.GetUserByID(r.Context(), userID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "user not found")
return
}
if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil {
httpStatus, code, msg := serviceErrToHTTP(err)
writeError(w, httpStatus, code, msg)
return
}
if req.Active {
h.audit.LogUserActivate(r.Context(), ac, userID, user.Email)
} else {
h.audit.LogUserDeactivate(r.Context(), ac, userID, user.Email)
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -32,7 +32,7 @@ type Server struct {
}
// New constructs the chi router and registers all routes.
// Extensions are called after core routes are registered, allowing enterprise
// Extensions are called after core routes are registered, allowing cloud
// or third-party code to add routes and middleware.
func New(
queries *db.Queries,
@ -85,12 +85,12 @@ func New(
apiKeys := newAPIKeyHandler(apiKeySvc, al)
hostH := newHostHandler(hostSvc, queries, al)
teamH := newTeamHandler(teamSvc, al, mailer)
usersH := newUsersHandler(queries, userSvc)
usersH := newUsersHandler(queries, userSvc, al)
auditH := newAuditHandler(auditSvc)
statsH := newStatsHandler(statsSvc)
usageH := newUsageHandler(usageSvc)
metricsH := newSandboxMetricsHandler(queries, pool)
buildH := newBuildHandler(buildSvc, queries, pool)
buildH := newBuildHandler(buildSvc, queries, pool, al)
channelH := newChannelHandler(channelSvc, al)
ptyH := newPtyHandler(queries, pool, jwtSecret)
processH := newProcessHandler(queries, pool, jwtSecret)
@ -255,6 +255,7 @@ func New(
r.Delete("/teams/{id}", teamH.AdminDeleteTeam)
r.Get("/users", usersH.AdminListUsers)
r.Put("/users/{id}/active", usersH.SetUserActive)
r.Get("/audit-logs", auditH.AdminList)
r.Get("/templates", buildH.ListTemplates)
r.Delete("/templates/{name}", buildH.DeleteTemplate)
r.Post("/builds", buildH.Create)