Skip to content

Commit 7d9a607

Browse files
authored
admin: CreateTable / DeleteTable write endpoints (P1, leader-only) (#634)
Stacked on #633 (the read-only chunk). Writes are limited to the leader node for now; follower-side `AdminForward` RPC (design Section 3.3 acceptance criteria 1-6) ships in a follow-up PR. Mergeable on its own — followers respond `503 leader_unavailable` + `Retry-After: 1`. ## Summary - `POST /admin/api/v1/dynamo/tables` and `DELETE /admin/api/v1/dynamo/tables/{name}` both go through the existing protect chain (BodyLimit → SessionAuth → Audit → CSRF). The handler also enforces `RoleFull` so a read-only key cannot create or delete even with a valid CSRF token. - Adapter side: `AdminCreateTable` / `AdminDeleteTable` take an `AdminPrincipal` and re-validate the role at the adapter layer even when a higher tier already enforced it. Preserves the design's *adapter side is the source of truth for authz* invariant (Section 3.2). Two sentinel errors (`ErrAdminNotLeader`, `ErrAdminForbidden`) signal the structured failure modes. - Bridge in `main_admin.go` translates adapter errors to admin sentinels (`ErrTablesNotLeader` to 503 + `Retry-After: 1`, `ErrTablesForbidden` to 403, `ResourceInUse` to 409, `ResourceNotFound` to 404, `ValidationException` to 400). Raw adapter error text is never surfaced to clients; everything else falls through to a generic 500 with the original message logged at error level. - Strict JSON decoding (`DisallowUnknownFields`); each validation message is plain English so the SPA can render it directly. - Two summary structs (`adapter.AdminCreateTableInput` / `admin.CreateTableRequest`) stay independent so neither package imports the other; the bridge keeps them in sync and any drift breaks the build there. ## Test plan - [x] `go build ./...` - [x] `go vet ./...` - [x] `golangci-lint run` (admin, adapter, root: 0 issues) - [x] `go test ./internal/admin/ -count=1` (49 tests pass — 14 new write-handler unit tests, 4 new server-level integration tests) - [x] `go test ./adapter/ -count=1 -run 'TestDynamoDB_Admin'` (14 tests pass — 9 new write-path tests including duplicate rejection, role enforcement at adapter, validation errors, delete missing to ResourceNotFound, etc.) - [ ] Manual smoke against a running node: - `curl -X POST .../dynamo/tables` with full-role cookies + CSRF header to 201 + JSON summary - same against a follower to 503 + `Retry-After: 1` - `DELETE` on a non-existent table to 404 `not_found` ## Stacked roadmap 1. **#633** read-only `GET /tables` + `GET /tables/{name}` (in review) 2. **THIS PR** — `POST` + `DELETE` (leader-only) 3. AdminForward RPC + follower-leader forwarding (Section 3.3 acceptance criteria 1-6) 4. S3 read-only endpoints 5. S3 write endpoints 6. SPA (React + Vite, embed.FS)
2 parents 8e3bb37 + 5783c2a commit 7d9a607

11 files changed

Lines changed: 3001 additions & 25 deletions

adapter/dynamodb_admin.go

Lines changed: 414 additions & 0 deletions
Large diffs are not rendered by default.

adapter/dynamodb_admin_test.go

Lines changed: 413 additions & 0 deletions
Large diffs are not rendered by default.

internal/admin/dynamo_handler.go

Lines changed: 710 additions & 0 deletions
Large diffs are not rendered by default.

internal/admin/dynamo_handler_test.go

Lines changed: 796 additions & 0 deletions
Large diffs are not rendered by default.

internal/admin/role_store.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package admin
2+
3+
// RoleStore is the live access-key → Role lookup the admin handlers
4+
// re-evaluate on every state-changing request. Embedding the role
5+
// in the JWT alone is insufficient: a token minted under role
6+
// `full` would otherwise keep mutating tables for the rest of its
7+
// 1-hour TTL even if an operator revoked or downgraded the access
8+
// key in the cluster's role configuration. Codex P1 on PR #635
9+
// flagged the gap; the leader-side ForwardServer already does this
10+
// re-evaluation, the HTTP path now does it too so leader-direct
11+
// writes match the forwarded path's authorisation contract.
12+
type RoleStore interface {
13+
// LookupRole returns the role for an access key as understood
14+
// by the local node's view of cluster configuration. The bool
15+
// is false when the access key is not in the admin role index
16+
// — a session whose key has been removed must not be able to
17+
// perform any admin write, even if its JWT is still within
18+
// its issued validity window.
19+
LookupRole(accessKey string) (Role, bool)
20+
}
21+
22+
// MapRoleStore is the trivial in-memory implementation, sufficient
23+
// for tests and for the production wiring (which already keeps the
24+
// role map in memory).
25+
type MapRoleStore map[string]Role
26+
27+
// LookupRole implements RoleStore.
28+
func (m MapRoleStore) LookupRole(accessKey string) (Role, bool) {
29+
r, ok := m[accessKey]
30+
return r, ok
31+
}

internal/admin/router.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,11 @@ func writeJSONNotFound(w http.ResponseWriter, _ *http.Request) {
277277

278278
func writeJSONError(w http.ResponseWriter, status int, code, msg string) {
279279
w.Header().Set("Content-Type", "application/json; charset=utf-8")
280+
// Defence-in-depth header: the admin surface is JSON-only, so
281+
// declare nosniff to prevent a misbehaving browser from
282+
// content-sniffing an error body into something executable.
283+
// Cheap and standard for cookie-gated admin endpoints.
284+
w.Header().Set("X-Content-Type-Options", "nosniff")
280285
w.Header().Set("Cache-Control", "no-store")
281286
w.WriteHeader(status)
282287
_ = json.NewEncoder(w).Encode(errorResponse{Error: code, Message: msg})

internal/admin/server.go

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"log/slog"
66
"net/http"
77
"reflect"
8+
"strings"
89
)
910

1011
// ServerDeps bundles the collaborators the admin HTTP surface needs. All
@@ -31,6 +32,13 @@ type ServerDeps struct {
3132
// ClusterInfo describes the local node's Raft state.
3233
ClusterInfo ClusterInfoSource
3334

35+
// Tables is the DynamoDB admin source — covers list, describe,
36+
// create, and delete via TablesSource. Optional: a nil value
37+
// disables /admin/api/v1/dynamo/tables{,/{name}} (the mux
38+
// answers them with 404). This lets a build that ships only the
39+
// cluster page deploy without standing up the dynamo bridge.
40+
Tables TablesSource
41+
3442
// StaticFS is the embed.FS (or any fs.FS) backing the SPA. May be
3543
// nil during early development; the router renders 404 for
3644
// /admin/assets/* and the SPA fallback in that case.
@@ -92,7 +100,21 @@ func NewServer(deps ServerDeps) (*Server, error) {
92100
}
93101
auth := NewAuthService(deps.Signer, deps.Credentials, deps.Roles, authOpts)
94102
cluster := NewClusterHandler(deps.ClusterInfo).WithLogger(logger)
95-
mux := buildAPIMux(auth, deps.Verifier, cluster, logger)
103+
var dynamo http.Handler
104+
if deps.Tables != nil {
105+
// Re-evaluate the principal's role on every state-
106+
// changing request against the live role map (Codex P1
107+
// on PR #635). MapRoleStore wraps the same map the auth
108+
// layer uses for login, so a config reload that updates
109+
// deps.Roles does NOT automatically propagate here —
110+
// operators must restart the listener for revocation to
111+
// take effect, but the JWT no longer extends a revoked
112+
// key past the next request.
113+
dynamo = NewDynamoHandler(deps.Tables).
114+
WithLogger(logger).
115+
WithRoleStore(MapRoleStore(deps.Roles))
116+
}
117+
mux := buildAPIMux(auth, deps.Verifier, cluster, dynamo, logger)
96118
router := NewRouter(mux, deps.StaticFS)
97119
return &Server{deps: deps, router: router, auth: auth, mux: mux}, nil
98120
}
@@ -119,15 +141,23 @@ func (s *Server) APIHandler() http.Handler {
119141
//
120142
// Layout:
121143
//
122-
// POST /admin/api/v1/auth/login (no auth, rate-limited)
123-
// POST /admin/api/v1/auth/logout (no auth required)
124-
// GET /admin/api/v1/cluster (auth required)
144+
// POST /admin/api/v1/auth/login (no auth, rate-limited)
145+
// POST /admin/api/v1/auth/logout (auth required)
146+
// GET /admin/api/v1/cluster (auth required)
147+
// GET /admin/api/v1/dynamo/tables (auth required)
148+
// POST /admin/api/v1/dynamo/tables (auth required, full role)
149+
// GET /admin/api/v1/dynamo/tables/{name} (auth required)
150+
// DELETE /admin/api/v1/dynamo/tables/{name} (auth required, full role)
125151
//
126152
// Body limit applies uniformly. CSRF and Audit middleware apply to
127153
// write-capable protected endpoints; login and logout carry their own
128154
// audit path inside AuthService because the generic Audit middleware
129155
// cannot see the claimed actor at that point in the chain.
130-
func buildAPIMux(auth *AuthService, verifier *Verifier, clusterHandler http.Handler, logger *slog.Logger) http.Handler {
156+
//
157+
// dynamoHandler may be nil; in that case the dynamo paths fall through
158+
// to the unknown-endpoint 404, matching the behaviour of any other
159+
// unregistered admin path.
160+
func buildAPIMux(auth *AuthService, verifier *Verifier, clusterHandler, dynamoHandler http.Handler, logger *slog.Logger) http.Handler {
131161
loginHandler := http.HandlerFunc(auth.HandleLogin)
132162
logoutHandler := http.HandlerFunc(auth.HandleLogout)
133163

@@ -177,15 +207,27 @@ func buildAPIMux(auth *AuthService, verifier *Verifier, clusterHandler http.Hand
177207
loginChain := publicAuth(loginHandler)
178208
logoutChain := protectNoAudit(logoutHandler)
179209
clusterChain := protect(clusterHandler)
210+
// Dynamo endpoints (reads and writes) share the protect chain
211+
// so a missing session or CSRF token 401s/403s the same way
212+
// regardless of method. The Audit middleware is a no-op for
213+
// GET (it only logs state-changing methods) so dashboard polls
214+
// don't flood the audit log, while POST/DELETE always do.
215+
var dynamoChain http.Handler
216+
if dynamoHandler != nil {
217+
dynamoChain = protect(dynamoHandler)
218+
}
180219

181220
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182-
switch r.URL.Path {
183-
case "/admin/api/v1/auth/login":
221+
switch {
222+
case r.URL.Path == "/admin/api/v1/auth/login":
184223
loginChain.ServeHTTP(w, r)
185-
case "/admin/api/v1/auth/logout":
224+
case r.URL.Path == "/admin/api/v1/auth/logout":
186225
logoutChain.ServeHTTP(w, r)
187-
case "/admin/api/v1/cluster":
226+
case r.URL.Path == "/admin/api/v1/cluster":
188227
clusterChain.ServeHTTP(w, r)
228+
case dynamoChain != nil && (r.URL.Path == pathDynamoTables ||
229+
strings.HasPrefix(r.URL.Path, pathPrefixDynamoTables)):
230+
dynamoChain.ServeHTTP(w, r)
189231
default:
190232
writeJSONError(w, http.StatusNotFound, "unknown_endpoint",
191233
"no admin API handler is registered for this path")

0 commit comments

Comments
 (0)