admin

package
v0.0.0-...-65e725a Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 29, 2026 License: AGPL-3.0 Imports: 28 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrTablesForbidden is returned when the principal lacks the
	// role required for the operation. Maps to 403.
	ErrTablesForbidden = errors.New("admin tables: principal lacks required role")
	// ErrTablesNotLeader is returned when the local node is not the
	// Raft leader. When a LeaderForwarder is configured,
	// tryForwardCreate / tryForwardDelete catch this before it reaches
	// writeTablesError and forward the request to the leader
	// transparently. Without a forwarder, maps to 503 + Retry-After: 1.
	ErrTablesNotLeader = errors.New("admin tables: local node is not the raft leader")
	// ErrTablesNotFound is returned when DELETE / DESCRIBE / a
	// follow-up read targets a table that does not exist. Maps to
	// 404. AdminDescribeTable's (nil, false, nil) tuple is the
	// preferred signal for the read path; this sentinel covers the
	// write paths only.
	ErrTablesNotFound = errors.New("admin tables: table not found")
	// ErrTablesAlreadyExists is returned when CreateTable hits a
	// pre-existing table with the same name. Maps to 409.
	ErrTablesAlreadyExists = errors.New("admin tables: table already exists")
)

Errors the source layer may return to signal a structured failure mode the handler maps to a specific HTTP response.

They are sentinel values so a bridge implementation can map any adapter-internal error onto exactly one of these without the admin package importing the adapter package's private types.

View Source
var (
	// ErrQueuesForbidden — principal lacks the role required (403).
	ErrQueuesForbidden = errors.New("admin sqs: principal lacks required role")
	// ErrQueuesNotLeader — local node is not the verified Raft
	// leader. Without follower-forwarding wired (out of scope for
	// the SPA's read+delete surface), maps to 503 + Retry-After: 1.
	ErrQueuesNotLeader = errors.New("admin sqs: local node is not the raft leader")
	// ErrQueuesNotFound — DELETE / DESCRIBE targets a queue that
	// does not exist (404). The describe path uses (nil, false, nil)
	// instead of this sentinel for the not-found signal.
	ErrQueuesNotFound = errors.New("admin sqs: queue not found")
	// ErrQueuesValidation — request shape is bad (400).
	ErrQueuesValidation = errors.New("admin sqs: validation failed")
)

Errors the QueuesSource may return for the handler to map onto a specific HTTP response. Sentinels rather than typed errors so the bridge can map any adapter-internal failure onto exactly one of these without the admin package importing adapter-private types.

View Source
var ErrBucketsAlreadyExists = errors.New("admin buckets: bucket already exists")

ErrBucketsAlreadyExists is returned when CreateBucket targets a name already in use. Maps to 409 Conflict.

View Source
var ErrBucketsForbidden = errors.New("admin buckets: principal lacks required role")

ErrBucketsForbidden is returned when the principal lacks the role required for the operation. Maps to 403. Kept as its own sentinel (rather than reusing ErrTablesForbidden) so a future per-resource role model can diverge without breaking either handler's match list.

View Source
var ErrBucketsNotEmpty = errors.New("admin buckets: bucket is not empty")

ErrBucketsNotEmpty is returned when DeleteBucket targets a bucket that still has objects. Maps to 409 Conflict — the dashboard cannot force a recursive delete; the SPA must guide the operator to clean up first. Mirrors the SigV4 deleteBucket path's BucketNotEmpty response.

View Source
var ErrBucketsNotFound = errors.New("admin buckets: bucket does not exist")

ErrBucketsNotFound is returned when DELETE / DESCRIBE / PutACL targets a bucket that does not exist. AdminDescribeBucket's (nil, false, nil) tuple is the preferred signal for the read path; this sentinel covers the write paths.

View Source
var ErrBucketsNotLeader = errors.New("admin buckets: local node is not the raft leader")

ErrBucketsNotLeader is returned when the local node is not the Raft leader for the S3 group. The HTTP handler maps this to 503 + Retry-After: 1 today; the next slice's AdminForward integration will catch this as the trigger to forward.

View Source
var ErrInvalidToken = errors.New("invalid admin session token")

ErrInvalidToken is returned for any verification failure without leaking which specific check failed. Callers should log the wrapped error but return a single 401 to clients regardless of the cause.

View Source
var ErrLeaderUnavailable = errors.New("admin: raft leader currently unavailable")

ErrLeaderUnavailable is returned when the forwarder cannot reach any leader — typically during a Raft election or a cluster split. The handler maps it to 503 + Retry-After: 1 so the SPA / client re-issues the request after a short delay (acceptance criterion 3).

Functions

func Audit

func Audit(logger *slog.Logger) func(http.Handler) http.Handler

Audit writes a structured slog line for every state-changing admin request, as required by docs/design Section 10. GET/HEAD requests are not audited (read traffic can be too loud and does not modify state). The logger uses the "admin_audit" key so operators can filter. Callers wire this middleware after SessionAuth so the principal is available.

func BodyLimit

func BodyLimit(limit int64) func(http.Handler) http.Handler

BodyLimit caps each request body at `limit` bytes via http.MaxBytesReader. Handlers that read the body are responsible for detecting overflow (via IsMaxBytesError / errors.As on *http.MaxBytesError) and calling WriteMaxBytesError to respond 413. We intentionally do not centralise that translation in the middleware chain: different handlers parse bodies with different decoders (json, form, multipart) and each already has a natural error path, so a wrapper ResponseWriter would either double-write or mask subsequent errors.

func CSRFDoubleSubmit

func CSRFDoubleSubmit() func(http.Handler) http.Handler

CSRFDoubleSubmit enforces the double-submit cookie rule for state changing methods (POST, PUT, DELETE, PATCH). The admin_csrf cookie is minted at login; the SPA copies its value into the X-Admin-CSRF header on every write. We reject the request if either the cookie or the header is missing or if they do not match. GET/HEAD pass through untouched.

func FormatBucketCreatedAt

func FormatBucketCreatedAt(hlc uint64) string

FormatBucketCreatedAt converts an HLC timestamp into the ISO-8601 string the SPA expects. Exposed (rather than kept package-private) so the bridge in main_admin.go can call it from the BucketsSource implementation — both the handler's response and any future audit-log enrichment land on identical formatting.

func IsMaxBytesError

func IsMaxBytesError(err error) bool

IsMaxBytesError reports whether err was produced because the client uploaded more bytes than BodyLimit permits.

func ParseTrustedProxies

func ParseTrustedProxies(specs []string) ([]*net.IPNet, error)

ParseTrustedProxies converts a list of CIDRs and bare IP literals into *net.IPNets, accepting both forms operators commonly write in config files. A bare IP is treated as a /32 (or /128 for IPv6) network. The function is exported so the wiring layer can pre-validate operator input at startup rather than discovering it on the first login.

func RequireWriteRole

func RequireWriteRole() func(http.Handler) http.Handler

RequireWriteRole blocks the handler unless the current principal may execute write operations. Must be composed after SessionAuth.

func SessionAuth

func SessionAuth(verifier *Verifier) func(http.Handler) http.Handler

SessionAuth parses the admin_session cookie, validates it against the verifier, and attaches the resulting AuthPrincipal to the request context. Requests without a session, or with an invalid/expired one, are rejected with 401.

func StaticFS

func StaticFS() (fs.FS, error)

StaticFS returns the io/fs.FS that backs /admin/assets/* and the SPA fallback. The returned FS is rooted at the embedded `dist` directory, so `index.html` resolves to `dist/index.html` and assets resolve to `dist/assets/*` — matching the pathing the Router expects.

When the SPA bundle has not been built (only the placeholder index.html that ships with the repo is present), the FS is still returned: the placeholder renders a short message telling the operator how to populate the bundle. Returning nil here would have the router answer with JSON 404, which is more confusing than a page that explains itself.

func SystemClock

func SystemClock() time.Time

SystemClock returns wall-clock time and is the default for production.

func WriteMaxBytesError

func WriteMaxBytesError(w http.ResponseWriter)

WriteMaxBytesError is the canonical 413 response body for admin handlers that detected an http.MaxBytesError while reading a request body. It keeps the JSON error shape consistent with the rest of the admin surface.

Types

type APIHandler

type APIHandler http.Handler

APIHandler is the bridge between the router and all JSON API endpoints. Everything under /admin/api/v1/ resolves through it; individual endpoint routing is the handler's responsibility (see apiMux below).

type AuthPrincipal

type AuthPrincipal struct {
	// AccessKey is the caller's SigV4 access key identifier.
	AccessKey string
	// Role is the role resolved from the server-side access key table.
	Role Role
}

AuthPrincipal is the authenticated caller derived from a session cookie or, in the future, a follower→leader forwarded request. The admin handler and adapter internal entrypoints pass it around instead of a raw HTTP request so the authorization model stays consistent whether the request arrived via SigV4 or JWT.

func PrincipalFromContext

func PrincipalFromContext(ctx context.Context) (AuthPrincipal, bool)

PrincipalFromContext returns the authenticated principal associated with the request context, or false if the middleware did not set one.

type AuthService

type AuthService struct {
	// contains filtered or unexported fields
}

AuthService wires the login/logout handlers, token minting, role lookup, and per-IP rate limiter together. Construct it once at startup and reuse across the admin listener's lifetime.

func NewAuthService

func NewAuthService(signer *Signer, creds CredentialStore, roles map[string]Role, opts AuthServiceOpts) *AuthService

NewAuthService constructs an AuthService. The signer must be primary (use NewSigner with the current key); token verification uses the Verifier passed separately to SessionAuth.

func (*AuthService) HandleLogin

func (s *AuthService) HandleLogin(w http.ResponseWriter, r *http.Request)

HandleLogin validates credentials and issues the session + CSRF cookies. It is safe to expose without the SessionAuth middleware because this is where a session first comes from; rate limiting, Content-Type validation, and constant-time credential comparison guard it.

Login events (success and failure) emit admin_audit slog entries directly. The generic Audit middleware cannot do this because it runs before the handler knows who the caller is claiming to be.

Preflight runs before any body inspection so a rejected request (wrong method, wrong content type, or rate-limited) returns without forcing a body read. That trade-off costs the claimed_actor signal on those audit lines — but the IP recorded by remote_addr is already enough to follow up on, and reading the body before throttling would let a hostile client hold handler goroutines open with slow bodies.

func (*AuthService) HandleLogout

func (s *AuthService) HandleLogout(w http.ResponseWriter, r *http.Request)

HandleLogout clears both cookies. The route is wired behind the protected middleware chain (SessionAuth + CSRF), so unauthenticated or cross-site callers are rejected before they reach this handler — that is what prevents logout-CSRF. SessionAuth has already populated the AuthPrincipal on the request context, so we reuse it for the audit line instead of re-parsing the session cookie ourselves.

type AuthServiceOpts

type AuthServiceOpts struct {
	// InsecureCookie disables the Secure attribute on the issued
	// cookies. It exists only for local plaintext-loopback development
	// and is expected to stay false in any real deployment.
	InsecureCookie bool
	// CookieDomain is optional and rarely used. Empty means "host-only
	// cookie", which is the default and the safest choice.
	CookieDomain string
	// LoginLimit is the per-IP rate limit (default 5).
	LoginLimit int
	// LoginWindow is the rate-limit window (default 1 minute).
	LoginWindow time.Duration
	// Clock drives rate-limiter aging. Defaults to SystemClock.
	Clock Clock
	// Logger is the slog destination for admin_audit entries emitted
	// by the login/logout handlers. nil falls back to slog.Default().
	Logger *slog.Logger
	// TrustedProxies is the set of *net.IPNets whose RemoteAddr is
	// allowed to substitute X-Forwarded-For for rate limiting. Empty
	// (the default) means the per-IP rate limiter always uses the
	// peer address — safe when admin runs on loopback or directly
	// behind users; necessary to override when a load balancer
	// terminates connections.
	TrustedProxies []*net.IPNet
}

AuthServiceOpts covers the knobs a caller may want to vary in tests. Zero values fall back to production defaults.

type BucketSummary

type BucketSummary struct {
	Name       string `json:"bucket_name"`
	ACL        string `json:"acl,omitempty"`
	CreatedAt  string `json:"created_at,omitempty"`
	Generation uint64 `json:"generation,omitempty"`
	Region     string `json:"region,omitempty"`
	Owner      string `json:"owner,omitempty"`
}

BucketSummary is the bucket-level DTO the SPA receives. The JSON shape matches the design doc Section 4.1 / web/admin's `S3Bucket` interface — bucket_name + acl + created_at — plus generation/region/owner for operators inspecting via curl.

CreatedAt is an ISO-8601 string (UTC, second precision). The adapter persists it as an HLC; the handler formats. Producing the formatted string here rather than in the SPA keeps timezone rendering server-side and prevents drift between the two SPA pages that surface buckets (S3List + S3Detail).

type BucketsSource

type BucketsSource interface {
	// AdminListBuckets returns every bucket this server knows about,
	// in stable lexicographic order. The empty list is a valid
	// response — the handler returns `{"buckets":[]}` rather than
	// 404 so the SPA can distinguish "no buckets yet" from "S3
	// admin not configured" (the latter shape is a 404 from the
	// router fallthrough).
	AdminListBuckets(ctx context.Context) ([]BucketSummary, error)
	// AdminDescribeBucket returns the metadata snapshot for name.
	// The triple (result, present, error) lets the handler emit a
	// 404 for missing buckets without sniffing sentinels; storage
	// failures still surface via the error return.
	AdminDescribeBucket(ctx context.Context, name string) (*BucketSummary, bool, error)
	// AdminCreateBucket creates a bucket atomically with its
	// initial ACL. Returns the freshly-stored summary on success;
	// ErrBucketsAlreadyExists / ErrBucketsForbidden /
	// ErrBucketsNotLeader / *ValidationError on the structured
	// failure paths.
	AdminCreateBucket(ctx context.Context, principal AuthPrincipal, in CreateBucketRequest) (*BucketSummary, error)
	// AdminPutBucketAcl updates the canned ACL on an existing
	// bucket. Returns ErrBucketsNotFound when the bucket is missing
	// and the same role / leader / validation sentinels as Create.
	AdminPutBucketAcl(ctx context.Context, principal AuthPrincipal, name, acl string) error
	// AdminDeleteBucket removes a bucket if it is empty. Returns
	// ErrBucketsNotFound for a missing bucket and ErrBucketsNotEmpty
	// when objects remain (the dashboard cannot force a recursive
	// delete; the SPA renders the 409 with a hint to clean up first).
	AdminDeleteBucket(ctx context.Context, principal AuthPrincipal, name string) error
}

BucketsSource is the in-process surface the admin S3 handler dispatches into. It mirrors TablesSource on the Dynamo side (Section 3.2 of the admin design): defining the contract here lets the bridge in main_admin.go translate adapter errors into the admin-package vocabulary without the adapter package importing internal/admin.

Read methods (List / Describe) are SigV4-bypass equivalents of the corresponding HTTP handlers. Write methods (Create / Delete / PutACL) additionally enforce a leader check and a Full-role principal check inside the adapter — duplicating the role check the handler already did defends against a follower-forwarded call smuggling a downgraded principal past the leader's view (Section 3.2 "認可の真実は常に adapter 側").

type Clock

type Clock func() time.Time

Clock is the small time abstraction used by the signer/verifier so tests can control token freshness without sleeping.

type ClusterHandler

type ClusterHandler struct {
	// contains filtered or unexported fields
}

ClusterHandler serves GET /admin/api/v1/cluster.

func NewClusterHandler

func NewClusterHandler(source ClusterInfoSource) *ClusterHandler

NewClusterHandler wires a source into the HTTP handler and seeds logging with slog.Default(). Callers that want a tagged logger can chain WithLogger(...) on the returned handler.

func (*ClusterHandler) ServeHTTP

func (h *ClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP renders the cluster snapshot as JSON. Errors from the source are logged on the server with full detail and surfaced to the client as a generic "cluster_describe_failed" code. Leaking err.Error() to unauthenticated-ish clients would reveal raft/store internals.

func (*ClusterHandler) WithLogger

func (h *ClusterHandler) WithLogger(l *slog.Logger) *ClusterHandler

WithLogger overrides the default slog destination. Kept as an option so main.go can attach a component tag without changing the constructor signature.

type ClusterInfo

type ClusterInfo struct {
	NodeID    string      `json:"node_id"`
	Version   string      `json:"version"`
	Timestamp time.Time   `json:"timestamp"`
	Groups    []GroupInfo `json:"groups"`
}

ClusterInfo is the lightweight snapshot the admin dashboard displays on its landing page. Everything here is cheap to assemble; we deliberately do not include per-shard key counts or byte statistics to keep the endpoint safe to poll.

type ClusterInfoFunc

type ClusterInfoFunc func(ctx context.Context) (ClusterInfo, error)

ClusterInfoFunc is a convenience adapter for wiring a plain function without defining an interface implementation.

func (ClusterInfoFunc) Describe

func (f ClusterInfoFunc) Describe(ctx context.Context) (ClusterInfo, error)

Describe implements ClusterInfoSource.

type ClusterInfoSource

type ClusterInfoSource interface {
	Describe(ctx context.Context) (ClusterInfo, error)
}

ClusterInfoSource is the small contract the cluster handler calls out to. Production wires this to a real Raft/engine view; tests use a stub.

type Config

type Config struct {
	// Enabled toggles the admin listener. Default false.
	Enabled bool

	// Listen is the host:port for the admin HTTP server. Default
	// 127.0.0.1:8080 (loopback only).
	Listen string

	// TLSCertFile / TLSKeyFile enable TLS when both are set.
	TLSCertFile string
	TLSKeyFile  string

	// AllowPlaintextNonLoopback opts out of the TLS-on-non-loopback
	// requirement. Refusing to honour it is the default.
	AllowPlaintextNonLoopback bool

	// SessionSigningKey is the base64-encoded cluster-wide HS256 key. It
	// must decode to exactly 64 bytes.
	SessionSigningKey string

	// SessionSigningKeyPrevious is an optional base64-encoded previous
	// signing key, used to verify tokens issued before a key rotation.
	SessionSigningKeyPrevious string

	// ReadOnlyAccessKeys grants the GET subset of admin endpoints.
	ReadOnlyAccessKeys []string

	// FullAccessKeys grants the full CRUD surface of admin endpoints.
	FullAccessKeys []string

	// AllowInsecureDevCookie turns off the always-on Secure cookie
	// attribute. Intended only for local plaintext development; it is off
	// by default and the startup banner calls it out loudly.
	AllowInsecureDevCookie bool
}

Config captures everything the admin listener needs at startup. It mirrors the Section 7.1 table in docs/design/2026_04_24_implemented_admin_dashboard.md and intentionally uses plain Go fields rather than a config library so the existing flag-based wiring in main.go can hand values over without a new dependency.

func (*Config) DecodedSigningKeys

func (c *Config) DecodedSigningKeys() ([][]byte, error)

DecodedSigningKeys returns the raw HS256 keys in verification order: the primary signing key first, followed by an optional previous key. Validate must be called first; this method also asserts that contract defensively so a missing key cannot quietly produce a `[][]byte{nil}` result and feed a nil HMAC key into the verifier.

func (*Config) RoleIndex

func (c *Config) RoleIndex() map[string]Role

RoleIndex returns a map from access key to Role after Validate has succeeded. The caller must not mutate the returned map.

func (*Config) Validate

func (c *Config) Validate() error

Validate returns the first configuration error found, if any. It does not try to collect every error because any of these conditions is a hard startup failure.

type CreateBucketRequest

type CreateBucketRequest struct {
	BucketName string `json:"bucket_name"`
	ACL        string `json:"acl,omitempty"`
}

CreateBucketRequest is the JSON body shape for POST /admin/api/v1/s3/buckets per design Section 4.2. ACL is optional; when omitted, the adapter defaults to "private", matching the SigV4 createBucket path's behaviour for a missing x-amz-acl header.

type CreateTableAttribute

type CreateTableAttribute struct {
	Name string `json:"name"`
	Type string `json:"type"`
}

CreateTableAttribute names a single primary-key or GSI key column. Type must be one of "S", "N", "B".

type CreateTableGSI

type CreateTableGSI struct {
	Name         string                `json:"name"`
	PartitionKey CreateTableAttribute  `json:"partition_key"`
	SortKey      *CreateTableAttribute `json:"sort_key,omitempty"`
	Projection   CreateTableProjection `json:"projection"`
}

CreateTableGSI describes a single global secondary index in a CreateTableRequest. SortKey is optional (hash-only GSI). When Projection.Type is "INCLUDE", Projection.NonKeyAttributes lists the projected attribute names; otherwise it is ignored.

type CreateTableProjection

type CreateTableProjection struct {
	Type             string   `json:"type,omitempty"`
	NonKeyAttributes []string `json:"non_key_attributes,omitempty"`
}

CreateTableProjection mirrors the DynamoDB Projection sub-struct in admin-friendly snake_case. Type defaults to "ALL" when omitted.

type CreateTableRequest

type CreateTableRequest struct {
	TableName    string                `json:"table_name"`
	PartitionKey CreateTableAttribute  `json:"partition_key"`
	SortKey      *CreateTableAttribute `json:"sort_key,omitempty"`
	GSI          []CreateTableGSI      `json:"gsi,omitempty"`
}

CreateTableRequest is the JSON body shape for POST /tables per design Section 4.2. The handler validates each field before passing the request to the source.

type CredentialStore

type CredentialStore interface {
	LookupSecret(accessKey string) (string, bool)
}

CredentialStore is the read-side view of the static SigV4 credential table the server was configured with. It returns the secret for a given access key, or ("", false) if the key is unknown. Supplying the same map the S3/DynamoDB adapters use keeps authentication consistent across the protocol surface.

type DynamoGSISummary

type DynamoGSISummary struct {
	Name           string `json:"name"`
	PartitionKey   string `json:"partition_key"`
	SortKey        string `json:"sort_key,omitempty"`
	ProjectionType string `json:"projection_type"`
}

DynamoGSISummary mirrors DynamoTableSummary for a single GSI.

type DynamoHandler

type DynamoHandler struct {
	// contains filtered or unexported fields
}

DynamoHandler serves /admin/api/v1/dynamo/tables and /admin/api/v1/dynamo/tables/{name}. The collection root accepts GET (list) and POST (create); the per-table route accepts GET (describe) and DELETE. Writes go through the same protected chain as reads (BodyLimit -> SessionAuth -> Audit -> CSRF) plus an in-handler RoleFull check so a read-only key cannot mutate even with a valid CSRF token.

Writes additionally re-resolve the principal's access key against a live RoleStore (when configured) so that a downgraded or revoked key cannot continue mutating with a still-valid JWT — the JWT freezes the role at login time, and tokens last one hour. Codex P1 on PR #635 flagged the gap on the HTTP path; the forward server already does this re-evaluation on its side.

When the source returns ErrTablesNotLeader and a LeaderForwarder is configured, write requests are forwarded to the leader transparently — the SPA sees a leader-direct response shape regardless of which node it hit (design Section 3.3 criterion 2).

func NewDynamoHandler

func NewDynamoHandler(source TablesSource) *DynamoHandler

NewDynamoHandler binds the source and seeds logging with slog.Default(). Use WithLogger to attach a tagged logger, WithRoleStore to plug in the live access-key role lookup, and WithLeaderForwarder to plug in the follower→leader forwarder.

func (*DynamoHandler) ServeHTTP

func (h *DynamoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP routes /tables and /tables/{name}. We do not use http.ServeMux because the admin router already guards the /admin/api/v1/* prefix — adding another mux here would just duplicate the path-parsing logic.

func (*DynamoHandler) WithLeaderForwarder

func (h *DynamoHandler) WithLeaderForwarder(f LeaderForwarder) *DynamoHandler

WithLeaderForwarder enables transparent follower→leader forwarding. Without it, write requests on a follower fall back to the standard 503 leader_unavailable response. Production wires this to the gRPCForwardClient in main_admin.go; tests inject a stub.

Asymmetric vs WithLogger by design: WithLogger no-ops on nil to preserve the slog.Default() seeded by NewDynamoHandler, but a nil forwarder here is a meaningful "disable forwarding" state (the gate in tryForwardCreate / tryForwardDelete checks for nil and falls back to the leader-only 503 path).

func (*DynamoHandler) WithLogger

func (h *DynamoHandler) WithLogger(l *slog.Logger) *DynamoHandler

WithLogger overrides the default slog destination.

func (*DynamoHandler) WithRoleStore

func (h *DynamoHandler) WithRoleStore(r RoleStore) *DynamoHandler

WithRoleStore enables per-request role revalidation on write endpoints. Without it, the handler trusts whatever role is embedded in the session JWT — which is fine for single-tenant deployments where the role config never changes, but problematic when an operator revokes or downgrades a key. The production wiring in main_admin.go always sets this.

type DynamoTableSummary

type DynamoTableSummary struct {
	Name                   string             `json:"name"`
	PartitionKey           string             `json:"partition_key"`
	SortKey                string             `json:"sort_key,omitempty"`
	Generation             uint64             `json:"generation"`
	GlobalSecondaryIndexes []DynamoGSISummary `json:"global_secondary_indexes,omitempty"`
}

DynamoTableSummary is the JSON shape the admin dashboard consumes. Defined in the admin package — rather than reusing the adapter's AdminTableSummary directly — so the admin HTTP layer does not pull in the heavyweight adapter dependency tree (gRPC, Raft, etc.) and remains testable in isolation. main_admin.go translates between adapter.AdminTableSummary and this type.

type FanoutNodeStatus

type FanoutNodeStatus struct {
	Node  string `json:"node"`
	OK    bool   `json:"ok"`
	Error string `json:"error,omitempty"`
}

FanoutNodeStatus is one node's contribution status for a single fan-out request. OK=true means the node returned a parseable matrix; OK=false carries the reason (timeout, refused, 5xx body, JSON decode failure). The local node always reports OK=true: its matrix is computed in-process and cannot fail in this layer.

type FanoutResult

type FanoutResult struct {
	Nodes     []FanoutNodeStatus `json:"nodes"`
	Responded int                `json:"responded"`
	Expected  int                `json:"expected"`
}

FanoutResult is the per-response fan-out summary attached to KeyVizMatrix.Fanout when fan-out is enabled. Nodes is ordered by the operator-supplied node list (self first) so the SPA can render a stable row order; Responded counts ok=true entries; Expected is the configured peer count plus self.

See docs/design/2026_04_27_proposed_keyviz_cluster_fanout.md 5.

type ForwardResult

type ForwardResult struct {
	StatusCode  int
	Payload     []byte
	ContentType string
}

ForwardResult is the leader's response replayed for the SPA. The handler writes Payload verbatim with the given status code and content type, so a forwarded request is indistinguishable from a leader-direct call.

type ForwardServer

type ForwardServer struct {
	pb.UnimplementedAdminForwardServer
	// contains filtered or unexported fields
}

ForwardServer is the leader-side gRPC handler for the AdminForward RPC (design Section 3.3). The follower's admin HTTP layer calls it when the local node is not the Raft leader; this server then re-validates the principal, dispatches the operation against the local TablesSource, and serialises the result back to the follower in the same JSON shape the SPA would have received from a leader-direct call.

The server is deliberately kept independent of the dynamo HTTP handler: it runs in the gRPC server's goroutine pool, not in the HTTP server's, and shares only the TablesSource interface (which the bridge in main_admin.go already implements for the local adapter).

func NewForwardServer

func NewForwardServer(source TablesSource, roles RoleStore, logger *slog.Logger) *ForwardServer

NewForwardServer wires a TablesSource and a RoleStore behind the gRPC AdminForward service. logger may be nil; defaults to slog.Default(). The S3 BucketsSource is plumbed via WithBucketsSource so deployments that ship without the S3 adapter can still register the gRPC service for Dynamo forwarding.

func (*ForwardServer) Forward

Forward is the gRPC entrypoint. It performs the principal re-evaluation the design mandates, then dispatches by operation. Errors that the SPA can act on are returned as a structured AdminForwardResponse with status_code + JSON payload; only fatal gRPC-layer errors (decode failure, unknown operation) come back as status.Errorf to the follower.

func (*ForwardServer) WithBucketsSource

func (s *ForwardServer) WithBucketsSource(b BucketsSource) *ForwardServer

WithBucketsSource enables S3 admin operation forwarding. Returns the receiver so wiring code can chain the call: `NewForwardServer(...).WithBucketsSource(...)`. A nil BucketsSource leaves S3 forwarding disabled — the Forward dispatcher rejects CREATE_BUCKET / DELETE_BUCKET / PUT_BUCKET_ACL with 501 in that case so a follower can detect the missing capability.

type GRPCConnFactory

type GRPCConnFactory interface {
	// ConnFor returns a gRPC client connection to addr, reusing
	// the cached entry if one exists. addr "" is a programming
	// error and may panic; callers must check leader-empty before
	// dialling.
	ConnFor(addr string) (PBAdminForwardClient, error)
}

GRPCConnFactory is the small surface AdminForwardClient needs from kv.GRPCConnCache. Pulling out an interface lets tests substitute an in-memory dialer without spinning up a TCP listener and lets the bridge use the existing connection cache without copy-paste.

type GroupInfo

type GroupInfo struct {
	GroupID  uint64   `json:"group_id"`
	LeaderID string   `json:"leader_id"`
	Members  []string `json:"members"`
	IsLeader bool     `json:"is_leader"`
}

GroupInfo describes a single Raft group from the local node's point of view. LeaderID is the empty string during an election or when the node has not yet discovered the leader.

type KeyVizFanout

type KeyVizFanout struct {
	// contains filtered or unexported fields
}

KeyVizFanout aggregates this node's local matrix with matrices fetched from a static peer list. The contract:

  • peers must NOT include self; the handler computes the local matrix and passes it to Run alongside the peer set.
  • Each peer is queried in parallel via HTTP GET on the same /admin/api/v1/keyviz/matrix path. The query string is rebuilt from the parsed parameters so a peer running an older or newer server does not receive an unrecognised parameter we never intended to forward.
  • A peer that times out, errors, or returns a non-OK status contributes a FanoutNodeStatus{OK: false, Error: ...} but does not abort the request. Aggregation proceeds with whatever succeeded.

The merge rules are documented in 4 of the design doc:

  • Reads / read_bytes: sum across nodes (each node served distinct follower reads).
  • Writes / write_bytes: max across nodes; when the per-cell values disagree we set Conflict=true on the row (best-effort dedup during a leadership flip; the canonical (raftGroupID, leaderTerm) dedup lands in Phase 2-C+ when we extend the wire format).

func NewKeyVizFanout

func NewKeyVizFanout(self string, peers []string) *KeyVizFanout

NewKeyVizFanout wires the aggregator. self is the local node's identity for the FanoutResult.Nodes entry (does not have to match any peer URL). peers is the list of HTTP base URLs to query (e.g. http://10.0.0.2:8080) — typically the operator's --keyvizFanoutNodes list with the local entry filtered out.

The default per-peer timeout is 2 seconds, matching the design 9 open question 2 default. The default HTTP client has no connection pool tuning beyond stdlib defaults; intra-cluster admin traffic does not yet justify a custom transport.

func (*KeyVizFanout) Run

func (f *KeyVizFanout) Run(ctx context.Context, params keyVizParams, local KeyVizMatrix, cookies []*http.Cookie) KeyVizMatrix

Run merges local with peer responses and returns the combined matrix plus per-node status. local is the matrix the handler already computed against the in-process sampler; on a single-node cluster (peers empty) Run returns local with a Fanout block that reports Expected=1, Responded=1.

cookies are attached to every peer request so the receiving node's SessionAuth middleware sees a valid admin session. Production passes the inbound request's cookies; nil disables cookie forwarding (peers will 401 unless they have their own bypass). All cluster nodes must share the same --adminSessionSigningKey for the cookie minted by node A to be verifiable on node B; the existing HA setup already requires this.

Run never returns an error: peer-level failures surface in the FanoutResult; aggregation is best-effort.

func (*KeyVizFanout) WithHTTPClient

func (f *KeyVizFanout) WithHTTPClient(c *http.Client) *KeyVizFanout

WithHTTPClient swaps the HTTP client. Tests inject an httptest server's Client(); operators may want a custom transport in the future. nil resets to the default.

func (*KeyVizFanout) WithLogger

func (f *KeyVizFanout) WithLogger(l *slog.Logger) *KeyVizFanout

WithLogger overrides the slog destination so main.go can attach a component tag. nil leaves the existing logger.

func (*KeyVizFanout) WithResponseBodyLimit

func (f *KeyVizFanout) WithResponseBodyLimit(n int64) *KeyVizFanout

WithResponseBodyLimit overrides the per-peer JSON decode cap. Production leaves this unset; tests use it to drive the over-cap path with a small synthetic body. Values <= 0 reset to the default.

func (*KeyVizFanout) WithTimeout

func (f *KeyVizFanout) WithTimeout(d time.Duration) *KeyVizFanout

WithTimeout sets the per-peer timeout (and updates the http.Client timeout when it has not been replaced via WithHTTPClient). Values <= 0 leave the existing timeout unchanged.

type KeyVizHandler

type KeyVizHandler struct {
	// contains filtered or unexported fields
}

KeyVizHandler serves GET /admin/api/v1/keyviz/matrix.

Query parameters (all optional):

series      - reads | writes | read_bytes | write_bytes (default: writes)
from_unix_ms - lower bound in unix ms; 0 or omitted means unbounded
               on that side (NOT the Unix epoch)
to_unix_ms   - upper bound in unix ms; same 0 = unbounded contract
rows         - row budget; default and maximum is 1024 (design §4.1).
               Omitted / 0 / negative all yield the cap; explicit
               values above the cap are silently clamped down.

Returns 503 codes.Unavailable when no sampler is configured so the SPA can distinguish "keyviz disabled" from "no data yet" (the latter is a successful empty matrix).

func NewKeyVizHandler

func NewKeyVizHandler(source KeyVizSource) *KeyVizHandler

NewKeyVizHandler wires a KeyVizSource into the HTTP handler. source may be nil; calls to ServeHTTP will then return 503 with code "keyviz_disabled".

func (*KeyVizHandler) ServeHTTP

func (h *KeyVizHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

func (*KeyVizHandler) WithClock

func (h *KeyVizHandler) WithClock(now func() time.Time) *KeyVizHandler

WithClock lets tests inject a deterministic GeneratedAt.

func (*KeyVizHandler) WithFanout

func (h *KeyVizHandler) WithFanout(f *KeyVizFanout) *KeyVizHandler

WithFanout enables cluster-wide fan-out aggregation. Pass nil to disable; passing a configured aggregator switches the handler to merge the local matrix with peer responses on every request.

func (*KeyVizHandler) WithLogger

func (h *KeyVizHandler) WithLogger(l *slog.Logger) *KeyVizHandler

WithLogger overrides the slog destination so main.go can attach a component tag without changing the constructor signature.

type KeyVizMatrix

type KeyVizMatrix struct {
	ColumnUnixMs []int64       `json:"column_unix_ms"`
	Rows         []KeyVizRow   `json:"rows"`
	Series       KeyVizSeries  `json:"series"`
	GeneratedAt  time.Time     `json:"generated_at"`
	Fanout       *FanoutResult `json:"fanout,omitempty"`
}

KeyVizMatrix is the row-major JSON wire form returned by /admin/api/v1/keyviz/matrix. Mirrors the proto GetKeyVizMatrixResponse shape so a future refactor can share a single pivot helper across the adapter (gRPC) and admin (JSON) paths.

Fanout is non-nil when the handler is configured for cluster-wide fan-out (Phase 2-C): it carries per-node status so the SPA can surface degraded responses inline (see design 2026_04_27_proposed_keyviz_cluster_fanout.md). The field is omitted from the wire form when fan-out is disabled so old clients keep working unchanged.

type KeyVizRow

type KeyVizRow struct {
	BucketID          string   `json:"bucket_id"`
	Start             []byte   `json:"start"`
	End               []byte   `json:"end"`
	Aggregate         bool     `json:"aggregate"`
	RouteIDs          []uint64 `json:"route_ids,omitempty"`
	RouteIDsTruncated bool     `json:"route_ids_truncated,omitempty"`
	RouteCount        uint64   `json:"route_count"`
	Values            []uint64 `json:"values"`
	Conflict          bool     `json:"conflict,omitempty"`
	// contains filtered or unexported fields
}

KeyVizRow is one route's worth of activity across the column window, matching the proto KeyVizRow layout. Values is parallel to KeyVizMatrix.ColumnUnixMs — Values[j] is the counter for that route at column j.

Conflict is true when the Phase 2-C max-merge collapsed disagreeing values from multiple nodes for the same row (see fan-out design 4.2); the SPA hatches such rows so operators know the displayed total may understate the true per-window count during a leadership flip. The flag is row-level for now and will move to per-cell when the proto extension lands in Phase 2-C+.

type KeyVizSeries

type KeyVizSeries string

KeyVizSeries selects which counter on a MatrixRow the response surfaces in `Values`. Wire form mirrors the proto enum but uses lowercase strings so the SPA can pass `?series=writes` directly without an extra encoding round-trip.

type KeyVizSource

type KeyVizSource interface {
	Snapshot(from, to time.Time) []keyviz.MatrixColumn
}

KeyVizSource is the small contract the keyviz handler depends on. Production wires this to a real *keyviz.MemSampler; tests use a stub that returns canned columns.

Snapshot returns the matrix columns within [from, to). Either bound may be the zero Time meaning unbounded on that side. Implementations MUST return rows the caller can mutate freely (a deep copy) — see keyviz.MemSampler.Snapshot.

type LeaderAddressResolver

type LeaderAddressResolver func() string

LeaderAddressResolver returns the current Raft leader's address for the local node's group, or "" if no leader is known. The production wiring uses raftengine.Engine.LeaderAddr / the cluster's address map; tests inject a fixed string.

type LeaderForwarder

type LeaderForwarder interface {
	// ForwardCreateTable issues a forwarded CreateTable on the
	// leader's behalf. The response is the leader's structured
	// AdminForwardResponse re-shaped into ForwardResult so the
	// handler does not need to import proto.
	ForwardCreateTable(ctx context.Context, principal AuthPrincipal, in CreateTableRequest) (*ForwardResult, error)
	// ForwardDeleteTable is the delete-side counterpart.
	ForwardDeleteTable(ctx context.Context, principal AuthPrincipal, name string) (*ForwardResult, error)
	// ForwardCreateBucket issues a forwarded POST /s3/buckets on
	// the leader's behalf. The leader echoes back the same JSON
	// envelope a leader-direct call would produce.
	ForwardCreateBucket(ctx context.Context, principal AuthPrincipal, in CreateBucketRequest) (*ForwardResult, error)
	// ForwardDeleteBucket is the delete-side counterpart.
	ForwardDeleteBucket(ctx context.Context, principal AuthPrincipal, name string) (*ForwardResult, error)
	// ForwardPutBucketAcl issues a forwarded PUT
	// /s3/buckets/{name}/acl. Both the bucket name and the
	// new ACL travel inside the proto payload — see the leader-side
	// handlePutBucketAcl for the JSON shape.
	ForwardPutBucketAcl(ctx context.Context, principal AuthPrincipal, name, acl string) (*ForwardResult, error)
}

LeaderForwarder is the contract the admin HTTP handler invokes when the local node is a follower (the source returned ErrTablesNotLeader). Implementations dial the current leader over the AdminForward gRPC service and return the leader's response in a transport-neutral shape so the handler can re-emit it verbatim.

Defining this interface in the admin package — rather than wiring pb.AdminForwardClient directly into the handler — keeps the admin HTTP layer free of any proto-level coupling and lets tests substitute a deterministic stub. The bridge in main_admin.go provides the production implementation that uses kv.GRPCConnCache + the raft engine's leader address.

func NewGRPCForwardClient

func NewGRPCForwardClient(resolver LeaderAddressResolver, dial GRPCConnFactory, nodeID string) (LeaderForwarder, error)

NewGRPCForwardClient constructs the production LeaderForwarder. All three parameters must be non-nil / non-empty; otherwise the constructor returns nil and a wiring-error so a misconfigured build refuses to start rather than producing 500s at runtime.

type LeaderProbe

type LeaderProbe interface {
	IsVerifiedLeader() bool
}

LeaderProbe is the cheap healthz contract used by /admin/healthz/leader. Implementations should return true only when this node is the verified Raft leader — a stale-leader follower returning true during a silent leadership change defeats the load balancer's purpose. Production wires this to the default group's coordinator IsLeader + VerifyLeader pair; tests use a stub returning a fixed bool.

A nil LeaderProbe makes /admin/healthz/leader unavailable — the router answers it with the standard JSON 404, distinguishing "not enabled" from the operational "503 not leader" state. Mirrors the S3/DynamoDB /healthz/leader contract.

type LeaderProbeFunc

type LeaderProbeFunc func() bool

LeaderProbeFunc is a convenience adapter for wiring a plain function without defining an interface implementation. Mirrors ClusterInfoFunc.

func (LeaderProbeFunc) IsVerifiedLeader

func (f LeaderProbeFunc) IsVerifiedLeader() bool

IsVerifiedLeader implements LeaderProbe.

type MapCredentialStore

type MapCredentialStore map[string]string

MapCredentialStore adapts a plain map into the CredentialStore interface. Callers typically load this from config at startup and hand the same map to the S3 adapter and the admin service.

func (MapCredentialStore) LookupSecret

func (m MapCredentialStore) LookupSecret(accessKey string) (string, bool)

LookupSecret implements CredentialStore.

type MapRoleStore

type MapRoleStore map[string]Role

MapRoleStore is the trivial in-memory implementation, sufficient for tests and for the production wiring (which already keeps the role map in memory).

func (MapRoleStore) LookupRole

func (m MapRoleStore) LookupRole(accessKey string) (Role, bool)

LookupRole implements RoleStore.

type PBAdminForwardClient

type PBAdminForwardClient interface {
	Forward(ctx context.Context, in *pb.AdminForwardRequest, opts ...grpc.CallOption) (*pb.AdminForwardResponse, error)
}

PBAdminForwardClient narrows pb.AdminForwardClient to just the methods this package uses. The narrower interface keeps the test stub implementation small.

The opts parameter must use grpc.CallOption (not interface{}) so the proto-generated *adminForwardClient satisfies this interface directly — Go interface satisfaction requires exact method- signature match, including variadic element types. Claude review on PR #644 caught the mismatch before the bridge tried to assign pb.NewAdminForwardClient(conn) and the build broke.

type PutBucketACLRequest

type PutBucketACLRequest struct {
	ACL string `json:"acl"`
}

PutBucketACLRequest is the JSON body shape for PUT /admin/api/v1/s3/buckets/{name}/acl. Single field so the body stays simple, but kept as a typed struct so a future "owner override" or "grants" extension does not require revisiting the wire format.

type QueueCounters

type QueueCounters struct {
	Visible    int64 `json:"visible"`
	NotVisible int64 `json:"not_visible"`
	Delayed    int64 `json:"delayed"`
}

QueueCounters mirrors the three Approximate* counters AWS exposes on GetQueueAttributes. Definitions follow §16.1 of the SQS design doc.

type QueueSummary

type QueueSummary struct {
	Name       string            `json:"name"`
	IsFIFO     bool              `json:"is_fifo"`
	Generation uint64            `json:"generation"`
	CreatedAt  *time.Time        `json:"created_at,omitempty"`
	Attributes map[string]string `json:"attributes,omitempty"`
	Counters   QueueCounters     `json:"counters"`
}

QueueSummary is the SPA-facing projection of a single SQS queue. Mirrors adapter.AdminQueueSummary 1:1; the bridge in main_admin.go translates between the two so the admin package stays free of the adapter dependency tree.

CreatedAt is a pointer so omitempty actually drops the field when the underlying queue has no wall-clock creation timestamp. Both encoding/json and goccy/go-json serialise a zero time.Time value as "0001-01-01T00:00:00Z" rather than dropping it, so the SPA would render an ancient date instead of the "—" placeholder its `created_at ? formatted : "—"` guard implies. The pointer makes the absent-vs-zero distinction explicit on the wire.

type QueuesSource

type QueuesSource interface {
	AdminListQueues(ctx context.Context) ([]string, error)
	AdminDescribeQueue(ctx context.Context, name string) (*QueueSummary, bool, error)
	AdminDeleteQueue(ctx context.Context, principal AuthPrincipal, name string) error
}

QueuesSource is the contract the SQS handler depends on. Wired in production to *adapter.SQSServer via a small bridge in main_admin.go; tests use a stub.

AdminDescribeQueue returns (nil, false, nil) for a missing queue so callers can distinguish "not found" from a storage error without sniffing sentinels. AdminDeleteQueue returns the structured sentinels below so the handler can map them to HTTP statuses without leaking the adapter's error vocabulary.

type Role

type Role string

Role represents the authorization tier of an authenticated admin session.

const (
	// RoleReadOnly permits only GET endpoints.
	RoleReadOnly Role = "read_only"
	// RoleFull permits the entire admin CRUD surface.
	RoleFull Role = "full"
)

func (Role) AllowsWrite

func (r Role) AllowsWrite() bool

AllowsWrite reports whether the role may execute state-mutating operations.

type RoleStore

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)
}

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 Router

type Router struct {
	// contains filtered or unexported fields
}

Router dispatches admin HTTP requests in the strict order mandated by the design doc (Section 5.3): API routes first, then healthz, then static assets, then SPA fallback. We do NOT use http.ServeMux because its LongestPrefix matching rules would let /admin/api/v1/... slip into the SPA catch-all when the JSON handler returns a 404.

func NewRouter

func NewRouter(api http.Handler, static fs.FS) *Router

NewRouter builds the admin router.

  • api handles /admin/api/v1/*. It must return a JSON body itself; the router never rewrites its response.
  • static, if non-nil, backs both /admin/assets/* and the /admin/* SPA catch-all (which always serves index.html). A nil static FS causes 404s for asset and SPA routes, which is the expected state while the SPA has not been built yet.

/admin/healthz/leader is wired via NewRouterWithLeaderProbe; this constructor leaves it unrouted (404) for callers that do not need the leader healthz endpoint.

func NewRouterWithLeaderProbe

func NewRouterWithLeaderProbe(api http.Handler, static fs.FS, leader LeaderProbe) *Router

NewRouterWithLeaderProbe is the long-form constructor used by production wiring (see ServerDeps.LeaderProbe). The probe drives /admin/healthz/leader: 200 when probe.IsVerifiedLeader() is true, 503 otherwise. A nil probe behaves identically to NewRouter — the path returns the standard JSON 404.

func (*Router) ServeHTTP

func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP is the single entrypoint. Routing cascade in priority order:

  1. /admin/api/v1/* → API handler
  2. /admin/healthz → plain text
  3. /admin/assets/* → static file
  4. /admin/* → index.html (SPA fallback)
  5. anything else → 404 JSON

type S3Handler

type S3Handler struct {
	// contains filtered or unexported fields
}

S3Handler serves /admin/api/v1/s3/buckets and the /admin/api/v1/s3/buckets/{name}{,/acl} sub-tree. Construct via NewS3Handler and hand to the admin router.

The handler depends on a BucketsSource for in-process dispatch. When source is nil the constructor returns nil, which is the well-known "S3 admin disabled" signal the router keys off of (the routes fall through to the unknown-endpoint 404).

Writes (POST / PUT / DELETE) re-validate the principal against a live RoleStore on every request — the JWT freezes the role at login but a downgraded or revoked key must not be allowed to continue mutating until the token expires (Codex P1 on PR #635 applied the same fix on the Dynamo side).

func NewS3Handler

func NewS3Handler(source BucketsSource) *S3Handler

NewS3Handler wires a BucketsSource into the HTTP handler. Returns nil when source is nil so a build that ships without the S3 adapter can pass the zero value to ServerDeps and have the routes silently disappear from the wire — matching the Tables nil contract on the Dynamo side.

func (*S3Handler) ServeHTTP

func (h *S3Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP routes /buckets, /buckets/{name}, and /buckets/{name}/acl.

func (*S3Handler) WithLeaderForwarder

func (h *S3Handler) WithLeaderForwarder(f LeaderForwarder) *S3Handler

WithLeaderForwarder wires the LeaderForwarder used to hand follower-side ErrBucketsNotLeader writes off to the leader. nil keeps forwarding disabled (the handler falls back to 503 + Retry-After:1 directly, mirroring DynamoHandler's contract).

func (*S3Handler) WithLogger

func (h *S3Handler) WithLogger(logger *slog.Logger) *S3Handler

WithLogger swaps the slog destination. Returns the receiver so option calls chain at construction sites (NewS3Handler(...).WithLogger(...).WithRoleStore(...)).

func (*S3Handler) WithRoleStore

func (h *S3Handler) WithRoleStore(roles RoleStore) *S3Handler

WithRoleStore wires the live access-key → role lookup. Required for the write endpoints' role re-validation; safe to omit on builds that disable writes (NewServer ensures it is always set when ServerDeps.Buckets is wired).

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is the composed admin HTTP handler. Obtain one via NewServer and hand its Handler() to an http.Server listening on the admin address.

func NewServer

func NewServer(deps ServerDeps) (*Server, error)

NewServer constructs the admin Server. It returns an error only if the dependencies are inconsistent enough to be unusable; otherwise it is total over its configuration space.

func (*Server) APIHandler

func (s *Server) APIHandler() http.Handler

APIHandler returns just the /admin/api/v1/* subtree. Tests that want to bypass static-file routing call this to avoid building an fs.FS.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler returns an http.Handler that serves the full admin surface. We wrap the router in BodyLimit at the top level so every endpoint — including /admin/healthz and the static asset / SPA paths — is protected from oversized request bodies. The login / logout / API chains apply BodyLimit again internally; both layers cap to the same default, so the effective limit stays at defaultBodyLimit and the redundant wrap is a no-op rather than a smaller cap.

type ServerDeps

type ServerDeps struct {
	// Signer issues session tokens. Keyed with the primary HS256 key.
	Signer *Signer

	// Verifier validates session tokens. Configured with the primary
	// key and, optionally, the previous key for rotation support.
	Verifier *Verifier

	// Credentials is the server-side access key → secret map used by
	// the login endpoint. Sharing this with the S3/DynamoDB adapters
	// keeps authentication consistent.
	Credentials CredentialStore

	// Roles maps each admin-allowed access key to its Role. Keys not
	// present are rejected at login with 403, even if their SigV4
	// secret validates against Credentials.
	Roles map[string]Role

	// 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

	// Forwarder is the LeaderForwarder that the Dynamo handler hands
	// off ErrTablesNotLeader writes to (design 3.3, AdminForward).
	// Optional: a nil value disables follower→leader forwarding, in
	// which case the handler surfaces 503 + Retry-After: 1 directly.
	// Single-node and leader-only deployments leave this nil; multi-
	// node clusters wire the production gRPC client.
	Forwarder LeaderForwarder

	// Buckets is the S3 admin source — read-only in this slice
	// (list + describe). Optional: a nil value disables
	// /admin/api/v1/s3/buckets{,/{name}} (the mux answers them
	// with 404). Mirrors the Tables nil contract for cluster-only
	// builds.
	Buckets BucketsSource

	// KeyViz exposes the keyviz heatmap matrix to the dashboard via
	// /admin/api/v1/keyviz/matrix. Optional: a nil value (or a node
	// started without --keyvizEnabled) makes the route return 503
	// codes "keyviz_disabled" so the SPA can render a clear "feature
	// off" state instead of an empty matrix.
	KeyViz KeyVizSource

	// KeyVizFanout enables Phase 2-C cluster-wide aggregation. When
	// non-nil, the keyviz handler merges the local matrix with the
	// configured peer set on every request. Optional: leaving it nil
	// preserves the legacy single-node behaviour.
	KeyVizFanout *KeyVizFanout

	// Queues is the SQS admin source — covers list, describe, and
	// delete via QueuesSource. Optional: a nil value disables
	// /admin/api/v1/sqs/queues{,/{name}} (the mux answers them
	// with 404). Same opt-in shape as Tables / Buckets; deployments
	// that don't run the SQS adapter omit this without breaking the
	// rest of the admin surface.
	Queues QueuesSource

	// 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.
	StaticFS fs.FS

	// LeaderProbe drives /admin/healthz/leader: 200 when probe.
	// IsVerifiedLeader() is true, 503 otherwise. A nil probe keeps the
	// path unrouted (returns the standard JSON 404), matching the
	// "feature off" pattern Tables / Buckets / Queues already use.
	// Production wires this to the default group's IsLeader +
	// VerifyLeader pair so a stale-leader follower in the middle of a
	// silent leadership change cannot return 200.
	LeaderProbe LeaderProbe

	// AuthOpts configures cookie attributes and rate limiting. Zero
	// values pick production-appropriate defaults.
	AuthOpts AuthServiceOpts

	// Logger is the slog destination for admin_audit entries. nil
	// falls back to slog.Default().
	Logger *slog.Logger
}

ServerDeps bundles the collaborators the admin HTTP surface needs. All fields are required unless noted otherwise. Construct once at startup and pass to NewServer.

type Signer

type Signer struct {
	// contains filtered or unexported fields
}

Signer issues HS256-signed JWTs using the primary admin signing key. Only the primary key can sign new tokens; the previous key is verify-only and lives on Verifier.

func NewSigner

func NewSigner(key []byte, clock Clock) (*Signer, error)

NewSigner constructs a Signer; key must be exactly sessionSigningKeyLen bytes (validated up-front so we do not catch this inside the hot path).

func (*Signer) Sign

func (s *Signer) Sign(principal AuthPrincipal) (string, error)

Sign mints a fresh JWT for principal with the admin session TTL.

type SqsHandler

type SqsHandler struct {
	// contains filtered or unexported fields
}

SqsHandler serves /admin/api/v1/sqs/queues and /admin/api/v1/sqs/queues/{name}. Reads (list, describe) accept GET; delete accepts DELETE and goes through the same protected middleware chain (BodyLimit -> SessionAuth -> Audit -> CSRF) as every other write surface, with an in-handler RoleFull gate so a read-only key cannot delete even with a valid CSRF token.

func NewSqsHandler

func NewSqsHandler(source QueuesSource) *SqsHandler

NewSqsHandler binds the source and seeds logging with slog.Default(). Use WithLogger to attach a tagged logger and WithRoleStore to plug in the live access-key role lookup so a downgraded key cannot continue mutating with a still-valid JWT.

func (*SqsHandler) ServeHTTP

func (h *SqsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP routes /queues and /queues/{name}. Method handling mirrors DynamoHandler — keep the two parallel so an operator reading one understands the other for free.

func (*SqsHandler) WithLogger

func (h *SqsHandler) WithLogger(l *slog.Logger) *SqsHandler

WithLogger overrides the default slog destination. No-ops on nil to preserve the constructor-seeded slog.Default().

func (*SqsHandler) WithRoleStore

func (h *SqsHandler) WithRoleStore(r RoleStore) *SqsHandler

WithRoleStore enables per-request role revalidation on the delete endpoint. Without it, the handler trusts whatever role is embedded in the session JWT — which is fine for single-tenant deployments where the role config never changes, but problematic when an operator revokes or downgrades a key. Production wiring in main_admin.go always sets this.

type TablesSource

type TablesSource interface {
	AdminListTables(ctx context.Context) ([]string, error)
	AdminDescribeTable(ctx context.Context, name string) (*DynamoTableSummary, bool, error)
	AdminCreateTable(ctx context.Context, principal AuthPrincipal, in CreateTableRequest) (*DynamoTableSummary, error)
	AdminDeleteTable(ctx context.Context, principal AuthPrincipal, name string) error
}

TablesSource is the contract the dynamo handler depends on. Wired in production to *adapter.DynamoDBServer via a small bridge in main_admin.go; tests use a stub.

AdminDescribeTable returns (nil, false, nil) for a missing table so callers can distinguish "not found" from a storage error without sniffing sentinels. The write entrypoints return the structured errors below (ErrTablesForbidden / ErrTablesNotLeader / ...) so the handler can map them to HTTP statuses without leaking the adapter's internal error shape into the admin package.

type ValidationError

type ValidationError struct{ Message string }

ValidationError is what the source returns when the input fails adapter-side validation. Surfaces a sanitised message back to the SPA — adapter-internal err.Error() output is never sent verbatim.

func (*ValidationError) Error

func (e *ValidationError) Error() string

type Verifier

type Verifier struct {
	// contains filtered or unexported fields
}

Verifier validates HS256 admin tokens. It tries the primary key first and falls back to the optional previous key so operators can rotate keys without logging everybody out at once.

func NewVerifier

func NewVerifier(keys [][]byte, clock Clock) (*Verifier, error)

NewVerifier builds a verifier from keys in priority order (primary first, optional previous second). Zero-length keys are rejected.

func (*Verifier) Verify

func (v *Verifier) Verify(token string) (AuthPrincipal, error)

Verify parses token, checks the signature against each configured key, and confirms it is within its validity window. On success it returns the embedded AuthPrincipal.

func (*Verifier) WithClockSkewTolerance

func (v *Verifier) WithClockSkewTolerance(d time.Duration) *Verifier

WithClockSkewTolerance overrides the future-issuance grace window. Operators in distributed environments where NTP synchronisation is loose may want a larger value to avoid spurious 401s when the signer's clock leads the verifier's by more than the default. Negative or zero durations fall back to defaultClockSkewTolerance.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL