Skip to content

Commit 8a9bab4

Browse files
authored
admin: P1 foundation (auth, router, cluster, listener) — no writes yet (#623)
## Summary First PR toward the admin dashboard designed in `docs/design/2026_04_24_proposed_admin_dashboard.md` (merged as #611). Introduces the **read-only foundation**: listener wiring, auth, router, middleware, cluster info / healthz endpoints. No write endpoints are included — per the design doc P1 DoD they ship together with `AdminForward` and the 3.3.2 acceptance criteria in a follow-up. ### What is in scope - `internal/admin/` package: config validation, JWT (HS256 + 2-key rotation), strict-prefix router, middleware chain (body limit, session auth, role gate, CSRF double-submit, audit slog), login/logout with per-IP rate limiter, cluster + healthz handlers, `Server` facade. - `main_admin.go`: flag wiring, config-to-`admin.Config` translation, TLS and loopback enforcement, errgroup lifecycle registration. - Full unit tests and two in-process integration tests (plaintext listener + self-signed TLS). ### What is NOT in scope (deferred to follow-up PRs) - `AdminForward` internal gRPC RPC and follower→leader forwarding (Section 3.3.2 acceptance criteria 1–6). - Adapter internal entrypoints taking `AuthPrincipal` (DynamoDB `CreateTable`/`DeleteTable`, S3 `CreateBucket`/`DeleteBucket`/`PutBucketAcl`). - Any write endpoint on the admin surface. - React SPA + `go:embed` (design P3). This keeps the first PR focused and reviewable; the DoD remains respected because no write endpoint ships without the acceptance criteria being green. ### Security posture - Admin is **off by default** (`-adminEnabled=false`), default bind is loopback. - Non-loopback bind without TLS is a **hard startup failure** unless the explicit opt-out flag is set. - Session cookie: `HttpOnly` + `Secure` + `SameSite=Strict` + `Path=/admin` + `Max-Age=3600`. - CSRF: double-submit cookie; `localStorage` is never used. - Role overlap between `read_only_access_keys` and `full_access_keys` is a hard startup failure (no silent last-writer-wins). - JWT signing key is cluster-shared and rotatable (primary + previous); missing key with admin enabled is a hard startup failure. - Login is rate-limited to 5 req/min per IP, constant-time secret comparison. - POST/PUT bodies are capped at 64 KiB via `http.MaxBytesReader`. - Every state-changing admin request is logged with `admin_audit` slog attributes. ## Test plan - [x] `go test -race ./internal/admin/... .` — green - [x] `golangci-lint run ./... --timeout=5m` — 0 issues - [x] In-process boot of admin listener over plaintext; `/admin/healthz` returns `ok` - [x] In-process boot with self-signed TLS; `/admin/healthz` over HTTPS returns `ok` - [x] Invalid config (non-loopback without TLS, missing signing key, duplicate role assignment, wrong-length base64 key) rejected at startup with a descriptive error - [x] Login happy path issues both cookies with the hardened attributes verified via regex - [x] Rate-limiter test: 6th login attempt from the same IP returns `429` + `Retry-After: 60`; different IPs are independent - [x] JWT verifier accepts tokens signed by the previous key during rotation and rejects them after rotation completes - [ ] Follow-up PR: AdminForward + write endpoints + acceptance-criteria test matrix ## Related - Design doc: #611 (merged) - Follow-up tracks AdminForward + write endpoints + SPA. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Optional admin HTTP API server with secure session tokens, CSRF protection, and audit logging * /admin/api/v1/auth/login and /admin/api/v1/auth/logout with hardened cookies and rate limiting * /admin/api/v1/cluster for cluster/status queries, /admin/healthz and static asset/SPA serving * Role-based access (read-only vs full) and support for signing-key rotation * **Tests** * Extensive end-to-end and unit tests covering auth, CSRF, rate limiting, routing, config validation, and JWT handling <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 4db754f + dbc9d33 commit 8a9bab4

26 files changed

Lines changed: 4531 additions & 3 deletions

internal/admin/auth_audit_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package admin
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
"time"
11+
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func newAuthServiceWithAudit(t *testing.T) (*AuthService, *bytes.Buffer) {
16+
t.Helper()
17+
clk := fixedClock(time.Unix(1_700_000_000, 0).UTC())
18+
signer := newSignerForTest(t, 1, clk)
19+
20+
creds := MapCredentialStore{
21+
"AKIA_ADMIN": "ADMIN_SECRET",
22+
}
23+
roles := map[string]Role{
24+
"AKIA_ADMIN": RoleFull,
25+
}
26+
buf := &bytes.Buffer{}
27+
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
28+
svc := NewAuthService(signer, creds, roles, AuthServiceOpts{
29+
Clock: clk,
30+
Logger: logger,
31+
})
32+
return svc, buf
33+
}
34+
35+
func TestAudit_LoginSuccessRecordsActor(t *testing.T) {
36+
svc, buf := newAuthServiceWithAudit(t)
37+
req := postJSON(t, loginRequest{AccessKey: "AKIA_ADMIN", SecretKey: "ADMIN_SECRET"})
38+
rec := httptest.NewRecorder()
39+
svc.HandleLogin(rec, req)
40+
41+
require.Equal(t, http.StatusOK, rec.Code)
42+
out := buf.String()
43+
require.Contains(t, out, `"msg":"admin_audit"`)
44+
require.Contains(t, out, `"action":"login"`)
45+
require.Contains(t, out, `"actor":"AKIA_ADMIN"`)
46+
require.Contains(t, out, `"claimed_actor":"AKIA_ADMIN"`)
47+
require.Contains(t, out, `"status":200`)
48+
}
49+
50+
func TestAudit_LoginFailureRecordsClaimedActor(t *testing.T) {
51+
svc, buf := newAuthServiceWithAudit(t)
52+
req := postJSON(t, loginRequest{AccessKey: "AKIA_ADMIN", SecretKey: "WRONG"})
53+
rec := httptest.NewRecorder()
54+
svc.HandleLogin(rec, req)
55+
56+
require.Equal(t, http.StatusUnauthorized, rec.Code)
57+
out := buf.String()
58+
require.Contains(t, out, `"action":"login"`)
59+
// We did NOT authenticate, so actor is empty.
60+
require.Contains(t, out, `"actor":""`)
61+
// But the claimed actor is still logged so operators can track
62+
// which access key was targeted by brute-force attempts.
63+
require.Contains(t, out, `"claimed_actor":"AKIA_ADMIN"`)
64+
require.Contains(t, out, `"status":401`)
65+
}
66+
67+
func TestAudit_LogoutReadsActorFromContext(t *testing.T) {
68+
svc, buf := newAuthServiceWithAudit(t)
69+
70+
// HandleLogout reads the principal from the request context (the
71+
// production wiring puts SessionAuth in front of it). Mirror that
72+
// here by injecting a principal directly so we can exercise the
73+
// audit branch without standing up the full router.
74+
req := httptest.NewRequest(http.MethodPost, "/admin/api/v1/auth/logout", nil)
75+
req.RemoteAddr = "127.0.0.1:1"
76+
ctx := context.WithValue(req.Context(), ctxKeyPrincipal,
77+
AuthPrincipal{AccessKey: "AKIA_ADMIN", Role: RoleFull})
78+
req = req.WithContext(ctx)
79+
80+
rec := httptest.NewRecorder()
81+
svc.HandleLogout(rec, req)
82+
83+
require.Equal(t, http.StatusNoContent, rec.Code)
84+
out := buf.String()
85+
require.Contains(t, out, `"action":"logout"`)
86+
require.Contains(t, out, `"actor":"AKIA_ADMIN"`)
87+
}
88+
89+
func TestAudit_LogoutWithoutCookieEmptyActor(t *testing.T) {
90+
svc, buf := newAuthServiceWithAudit(t)
91+
req := httptest.NewRequest(http.MethodPost, "/admin/api/v1/auth/logout", nil)
92+
req.RemoteAddr = "127.0.0.1:1"
93+
rec := httptest.NewRecorder()
94+
svc.HandleLogout(rec, req)
95+
96+
require.Equal(t, http.StatusNoContent, rec.Code)
97+
out := buf.String()
98+
require.Contains(t, out, `"action":"logout"`)
99+
require.Contains(t, out, `"actor":""`)
100+
}
101+
102+
func TestAudit_LoginLengthTimingHashed(t *testing.T) {
103+
// Same-length secret mismatch and different-length secret mismatch
104+
// must both reach the failure path without short-circuiting on
105+
// length. We cannot time them precisely in a unit test, but we can
106+
// at least verify both paths emit the same failure response.
107+
svc, _ := newAuthServiceWithAudit(t)
108+
for _, secret := range []string{"x", "much-longer-wrong-secret-value-here"} {
109+
req := postJSON(t, loginRequest{AccessKey: "AKIA_ADMIN", SecretKey: secret})
110+
rec := httptest.NewRecorder()
111+
svc.HandleLogin(rec, req)
112+
require.Equal(t, http.StatusUnauthorized, rec.Code)
113+
require.Contains(t, rec.Body.String(), "invalid_credentials")
114+
}
115+
}

0 commit comments

Comments
 (0)