// Package security exposes admin-only HTTP endpoints for secret/key management: // rotating the master key (bumping crypto_keys active version + enqueuing the // re-encrypt job) and reporting re-encryption progress. All routes sit behind // middleware.Auth + an explicit is_admin gate (reusing the userAdminAdapter // pattern shared across the codebase), so an agent / non-admin token cannot // trigger a rotation. package security import ( "context" "encoding/json" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/jobs" "github.com/yan1h/agent-coding-workflow/internal/jobs/handlers" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" ) // AdminLookup resolves is_admin for a user (same narrow interface used across // handlers; the shared userAdminAdapter satisfies it). type AdminLookup interface { IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) } // KeyRotator bumps the active crypto key version. The Postgres key store // satisfies this. type KeyRotator interface { RotateActive(ctx context.Context, provider, keyRef string) (int, error) ActiveVersion(ctx context.Context) (int, error) } // JobEnqueuer enqueues background jobs. jobs.Repository satisfies it. type JobEnqueuer interface { Enqueue(ctx context.Context, typ jobs.JobType, payload json.RawMessage, scheduledAt time.Time, maxAttempts int32) (*jobs.Job, error) } // StatusReporter reports re-encryption progress per table. The Postgres // re-encrypt store satisfies it. type StatusReporter interface { Tables() []string StaleCount(ctx context.Context, table string, activeVersion int) (int, error) } // Handler wires the security admin endpoints. type Handler struct { rotator KeyRotator enqueuer JobEnqueuer status StatusReporter resolver middleware.SessionResolver users AdminLookup audit audit.Recorder } // NewHandler constructs the security Handler. audit may be nil (best-effort). func NewHandler(rotator KeyRotator, enqueuer JobEnqueuer, status StatusReporter, resolver middleware.SessionResolver, users AdminLookup, rec audit.Recorder) *Handler { return &Handler{rotator: rotator, enqueuer: enqueuer, status: status, resolver: resolver, users: users, audit: rec} } // Mount registers the admin security routes under middleware.Auth. func (h *Handler) Mount(r chi.Router) { auth := middleware.Auth(h.resolver, middleware.AuthOptions{}) r.With(auth).Route("/api/v1/admin/security", func(r chi.Router) { r.Post("/rotate-key", h.rotateKey) r.Get("/reencrypt-status", h.reencryptStatus) }) } // requireAdmin resolves the actor and enforces is_admin. func (h *Handler) requireAdmin(r *http.Request) (uuid.UUID, error) { uid, ok := middleware.UserIDFromContext(r.Context()) if !ok { return uuid.Nil, errs.New(errs.CodeUnauthorized, "unauthenticated") } isAdmin, err := h.users.IsAdmin(r.Context(), uid) if err != nil { return uuid.Nil, err } if !isAdmin { return uuid.Nil, errs.New(errs.CodeForbidden, "需要管理员权限") } return uid, nil } // rotateKeyReq is the optional request body for POST /rotate-key. type rotateKeyReq struct { Provider string `json:"provider"` // 默认 env KeyRef string `json:"key_ref"` // KMS/Vault 引用,env provider 留空 } // rotateKeyResp reports the new active version and the enqueued job. type rotateKeyResp struct { ActiveVersion int `json:"active_version"` JobID string `json:"job_id"` } // rotateKey bumps the active key version and enqueues the re-encrypt job. The // new version's key material must already be loadable by the provider (e.g. // APP_MASTER_KEY_V staged) — otherwise the re-encrypt job's seals will fail // and the job retries until the operator stages the key. The OLD version stays // loadable so existing blobs decrypt during the rotation window. func (h *Handler) rotateKey(w http.ResponseWriter, r *http.Request) { actor, err := h.requireAdmin(r) if err != nil { h.writeErr(w, r, err) return } var req rotateKeyReq if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) // body optional } provider := req.Provider if provider == "" { provider = "env" } newVer, err := h.rotator.RotateActive(r.Context(), provider, req.KeyRef) if err != nil { h.writeErr(w, r, errs.Wrap(err, errs.CodeInternal, "rotate key")) return } payload, _ := json.Marshal(handlers.SecretReencryptPayload{ActiveVersion: newVer}) job, err := h.enqueuer.Enqueue(r.Context(), jobs.TypeSecretReencrypt, payload, time.Now(), 5) if err != nil { h.writeErr(w, r, errs.Wrap(err, errs.CodeInternal, "enqueue reencrypt")) return } if h.audit != nil { a := actor _ = h.audit.Record(context.WithoutCancel(r.Context()), audit.Entry{ UserID: &a, Action: "security.rotate_key", TargetType: "crypto_key", TargetID: job.ID.String(), Metadata: map[string]any{"active_version": newVer, "job_id": job.ID.String()}, }) } httpx.WriteJSON(w, http.StatusAccepted, rotateKeyResp{ActiveVersion: newVer, JobID: job.ID.String()}) } // reencryptStatusResp reports rows remaining on a stale key version per table. type reencryptStatusResp struct { ActiveVersion int `json:"active_version"` Stale map[string]int `json:"stale"` Complete bool `json:"complete"` } // reencryptStatus reports per-table counts of rows still on an old key version. func (h *Handler) reencryptStatus(w http.ResponseWriter, r *http.Request) { if _, err := h.requireAdmin(r); err != nil { h.writeErr(w, r, err) return } active, err := h.rotator.ActiveVersion(r.Context()) if err != nil { h.writeErr(w, r, errs.Wrap(err, errs.CodeInternal, "active version")) return } stale := map[string]int{} total := 0 for _, t := range h.status.Tables() { n, err := h.status.StaleCount(r.Context(), t, active) if err != nil { h.writeErr(w, r, errs.Wrap(err, errs.CodeInternal, "stale count")) return } stale[t] = n total += n } httpx.WriteJSON(w, http.StatusOK, reencryptStatusResp{ ActiveVersion: active, Stale: stale, Complete: total == 0, }) } func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) { httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) }