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:
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user