You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// 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<n> 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)
|
||||
}
|
||||
Reference in New Issue
Block a user