Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
25be7d1
admin: add read-only DynamoDB tables endpoints (P1)
bootjp Apr 25, 2026
ea98801
admin: marshal-then-write JSON responses (Gemini medium)
bootjp Apr 25, 2026
b2dec7a
admin: address CodeRabbit nitpicks (readonly review)
bootjp Apr 25, 2026
455ad39
admin: drop migration side-effect from describe + tighten list doc
bootjp Apr 25, 2026
3611381
admin: nosniff header + drop dead nil-check (Claude review)
bootjp Apr 25, 2026
d842885
admin: add CreateTable / DeleteTable write endpoints (P1, leader-only)
bootjp Apr 25, 2026
70bf37d
admin: address Gemini + Codex review (write endpoints)
bootjp Apr 25, 2026
2499798
admin: reject slash-bearing table names at create time (Codex P2)
bootjp Apr 25, 2026
82578c0
admin: canonicalise GSI projection_type at validation (Codex P2)
bootjp Apr 25, 2026
04345d7
admin: reject NUL-byte payload smuggling (Codex P2)
bootjp Apr 25, 2026
593cfba
admin: oversized create-table body returns 413, not 400 (Codex P2)
bootjp Apr 25, 2026
b139dae
admin: map leader-churn dispatch errors to 503 (Codex P2)
bootjp Apr 25, 2026
917ad33
admin: tighten leader-churn matcher (Codex P2 follow-up)
bootjp Apr 25, 2026
cfc50a5
admin: refresh DynamoHandler doc + trim table_name (Claude review)
bootjp Apr 25, 2026
c2bfcd0
admin: revalidate write role against live role map (Codex P1)
bootjp Apr 25, 2026
17c0fcd
Merge branch 'main' into feat/admin-dynamo-tables-write
bootjp Apr 25, 2026
56ba7b3
admin: refresh stale doc comments after write endpoints (Claude review)
bootjp Apr 25, 2026
5783c2a
Merge branch 'main' into feat/admin-dynamo-tables-write
bootjp Apr 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
414 changes: 414 additions & 0 deletions adapter/dynamodb_admin.go

Large diffs are not rendered by default.

413 changes: 413 additions & 0 deletions adapter/dynamodb_admin_test.go

Large diffs are not rendered by default.

710 changes: 710 additions & 0 deletions internal/admin/dynamo_handler.go

Large diffs are not rendered by default.

796 changes: 796 additions & 0 deletions internal/admin/dynamo_handler_test.go

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions internal/admin/role_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package admin

// RoleStore is the live access-key → Role lookup the admin handlers
// re-evaluate on every state-changing request. Embedding the role
// in the JWT alone is insufficient: a token minted under role
// `full` would otherwise keep mutating tables for the rest of its
// 1-hour TTL even if an operator revoked or downgraded the access
// key in the cluster's role configuration. Codex P1 on PR #635
// flagged the gap; the leader-side ForwardServer already does this
// re-evaluation, the HTTP path now does it too so leader-direct
// writes match the forwarded path's authorisation contract.
type RoleStore interface {
// LookupRole returns the role for an access key as understood
// by the local node's view of cluster configuration. The bool
// is false when the access key is not in the admin role index
// — a session whose key has been removed must not be able to
// perform any admin write, even if its JWT is still within
// its issued validity window.
LookupRole(accessKey string) (Role, bool)
}

// MapRoleStore is the trivial in-memory implementation, sufficient
// for tests and for the production wiring (which already keeps the
// role map in memory).
type MapRoleStore map[string]Role

// LookupRole implements RoleStore.
func (m MapRoleStore) LookupRole(accessKey string) (Role, bool) {
r, ok := m[accessKey]
return r, ok
}
5 changes: 5 additions & 0 deletions internal/admin/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ func writeJSONNotFound(w http.ResponseWriter, _ *http.Request) {

func writeJSONError(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// Defence-in-depth header: the admin surface is JSON-only, so
// declare nosniff to prevent a misbehaving browser from
// content-sniffing an error body into something executable.
// Cheap and standard for cookie-gated admin endpoints.
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: code, Message: msg})
Expand Down
60 changes: 51 additions & 9 deletions internal/admin/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"reflect"
"strings"
)

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

// Tables is the DynamoDB admin source — covers list, describe,
// create, and delete via TablesSource. Optional: a nil value
// disables /admin/api/v1/dynamo/tables{,/{name}} (the mux
// answers them with 404). This lets a build that ships only the
// cluster page deploy without standing up the dynamo bridge.
Tables TablesSource

// StaticFS is the embed.FS (or any fs.FS) backing the SPA. May be
// nil during early development; the router renders 404 for
// /admin/assets/* and the SPA fallback in that case.
Expand Down Expand Up @@ -92,7 +100,21 @@ func NewServer(deps ServerDeps) (*Server, error) {
}
auth := NewAuthService(deps.Signer, deps.Credentials, deps.Roles, authOpts)
cluster := NewClusterHandler(deps.ClusterInfo).WithLogger(logger)
mux := buildAPIMux(auth, deps.Verifier, cluster, logger)
var dynamo http.Handler
if deps.Tables != nil {
// Re-evaluate the principal's role on every state-
// changing request against the live role map (Codex P1
// on PR #635). MapRoleStore wraps the same map the auth
// layer uses for login, so a config reload that updates
// deps.Roles does NOT automatically propagate here —
// operators must restart the listener for revocation to
// take effect, but the JWT no longer extends a revoked
// key past the next request.
dynamo = NewDynamoHandler(deps.Tables).
WithLogger(logger).
WithRoleStore(MapRoleStore(deps.Roles))
}
mux := buildAPIMux(auth, deps.Verifier, cluster, dynamo, logger)
router := NewRouter(mux, deps.StaticFS)
return &Server{deps: deps, router: router, auth: auth, mux: mux}, nil
}
Expand All @@ -119,15 +141,23 @@ func (s *Server) APIHandler() http.Handler {
//
// Layout:
//
// POST /admin/api/v1/auth/login (no auth, rate-limited)
// POST /admin/api/v1/auth/logout (no auth required)
// GET /admin/api/v1/cluster (auth required)
// POST /admin/api/v1/auth/login (no auth, rate-limited)
// POST /admin/api/v1/auth/logout (auth required)
// GET /admin/api/v1/cluster (auth required)
// GET /admin/api/v1/dynamo/tables (auth required)
// POST /admin/api/v1/dynamo/tables (auth required, full role)
// GET /admin/api/v1/dynamo/tables/{name} (auth required)
// DELETE /admin/api/v1/dynamo/tables/{name} (auth required, full role)
//
// Body limit applies uniformly. CSRF and Audit middleware apply to
// write-capable protected endpoints; login and logout carry their own
// audit path inside AuthService because the generic Audit middleware
// cannot see the claimed actor at that point in the chain.
func buildAPIMux(auth *AuthService, verifier *Verifier, clusterHandler http.Handler, logger *slog.Logger) http.Handler {
//
// dynamoHandler may be nil; in that case the dynamo paths fall through
// to the unknown-endpoint 404, matching the behaviour of any other
// unregistered admin path.
func buildAPIMux(auth *AuthService, verifier *Verifier, clusterHandler, dynamoHandler http.Handler, logger *slog.Logger) http.Handler {
loginHandler := http.HandlerFunc(auth.HandleLogin)
logoutHandler := http.HandlerFunc(auth.HandleLogout)

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

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/admin/api/v1/auth/login":
switch {
case r.URL.Path == "/admin/api/v1/auth/login":
loginChain.ServeHTTP(w, r)
case "/admin/api/v1/auth/logout":
case r.URL.Path == "/admin/api/v1/auth/logout":
logoutChain.ServeHTTP(w, r)
case "/admin/api/v1/cluster":
case r.URL.Path == "/admin/api/v1/cluster":
clusterChain.ServeHTTP(w, r)
case dynamoChain != nil && (r.URL.Path == pathDynamoTables ||
strings.HasPrefix(r.URL.Path, pathPrefixDynamoTables)):
dynamoChain.ServeHTTP(w, r)
default:
writeJSONError(w, http.StatusNotFound, "unknown_endpoint",
"no admin API handler is registered for this path")
Expand Down
Loading
Loading