This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+189
View File
@@ -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)
}
+122
View File
@@ -0,0 +1,122 @@
package security
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/jobs"
)
type fakeRotator struct {
active int
rotated bool
}
func (f *fakeRotator) RotateActive(_ context.Context, _, _ string) (int, error) {
f.active++
f.rotated = true
return f.active, nil
}
func (f *fakeRotator) ActiveVersion(context.Context) (int, error) { return f.active, nil }
type fakeEnqueuer struct{ enqueued jobs.JobType }
func (f *fakeEnqueuer) Enqueue(_ context.Context, typ jobs.JobType, _ json.RawMessage, _ time.Time, _ int32) (*jobs.Job, error) {
f.enqueued = typ
return &jobs.Job{ID: uuid.New(), Type: typ}, nil
}
type fakeStatus struct{ stale map[string]int }
func (f *fakeStatus) Tables() []string { return []string{"workspaces"} }
func (f *fakeStatus) StaleCount(_ context.Context, t string, _ int) (int, error) {
return f.stale[t], nil
}
// fakeResolver maps a fixed token to a fixed user id.
type fakeResolver struct{ uid uuid.UUID }
func (f fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
if token == "good" {
return f.uid, true, nil
}
return uuid.Nil, false, nil
}
type fakeAdmin struct{ admins map[uuid.UUID]bool }
func (f fakeAdmin) IsAdmin(_ context.Context, id uuid.UUID) (bool, error) {
return f.admins[id], nil
}
func mountHandler(t *testing.T, admin bool) (*chi.Mux, *fakeRotator, *fakeEnqueuer) {
t.Helper()
uid := uuid.New()
rot := &fakeRotator{active: 1}
enq := &fakeEnqueuer{}
st := &fakeStatus{stale: map[string]int{"workspaces": 2}}
h := NewHandler(rot, enq, st, fakeResolver{uid: uid}, fakeAdmin{admins: map[uuid.UUID]bool{uid: admin}}, nil)
r := chi.NewRouter()
h.Mount(r)
return r, rot, enq
}
func TestRotateKey_AdminEnqueuesReencrypt(t *testing.T) {
r, rot, enq := mountHandler(t, true)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/security/rotate-key", nil)
req.Header.Set("Authorization", "Bearer good")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusAccepted, rr.Code)
require.True(t, rot.rotated)
require.Equal(t, jobs.TypeSecretReencrypt, enq.enqueued)
var resp rotateKeyResp
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
require.Equal(t, 2, resp.ActiveVersion)
require.NotEmpty(t, resp.JobID)
}
func TestRotateKey_NonAdminForbidden(t *testing.T) {
r, rot, enq := mountHandler(t, false)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/security/rotate-key", nil)
req.Header.Set("Authorization", "Bearer good")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusForbidden, rr.Code)
require.False(t, rot.rotated)
require.Empty(t, enq.enqueued)
}
func TestRotateKey_Unauthenticated(t *testing.T) {
r, _, _ := mountHandler(t, true)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/security/rotate-key", nil)
req.Header.Set("Authorization", "Bearer bad")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestReencryptStatus_ReportsStale(t *testing.T) {
r, _, _ := mountHandler(t, true)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/security/reencrypt-status", nil)
req.Header.Set("Authorization", "Bearer good")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
var resp reencryptStatusResp
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
require.Equal(t, 2, resp.Stale["workspaces"])
require.False(t, resp.Complete)
}