admin

package
v0.0.0-...-ebdf8eb Latest Latest
Warning

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

Go to latest
Published: Jun 18, 2026 License: AGPL-3.0 Imports: 29 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")
	// ErrQueuesPurgeInProgress — the queue's 60-second
	// PurgeQueue cooldown is active (429). The handler matches
	// against this sentinel and pulls RetryAfter from a typed
	// PurgeInProgressError wrapping it, so callers can branch via
	// errors.Is while extracting the duration via errors.As.
	ErrQueuesPurgeInProgress = errors.New("admin sqs: purge in progress")
)

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 ErrItemsForbidden = errors.New("admin dynamo items: forbidden")

ErrItemsForbidden — principal lacks the required role (RoleReadOnly for scan/get, RoleFull for put/delete). Maps to 403.

View Source
var ErrItemsNotLeader = errors.New("admin dynamo items: not leader")

ErrItemsNotLeader — this node is not the Raft leader; SPA should retry against the leader returned by the leader-probe endpoint. Maps to 503.

View Source
var ErrItemsTableNotFound = errors.New("admin dynamo items: table not found")

ErrItemsTableNotFound — the named table does not exist. Maps to 404.

View Source
var ErrItemsValidation = errors.New("admin dynamo items: invalid request")

ErrItemsValidation — empty / malformed key, body shape mismatch, nesting depth exceeded, unknown kind tag. Maps to 400.

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

View Source
var ErrObjectsBucketNotFound = errors.New("admin s3 objects: bucket not found")

ErrObjectsBucketNotFound — the named bucket does not exist. 404.

View Source
var ErrObjectsForbidden = errors.New("admin s3 objects: forbidden")

ErrObjectsForbidden — principal lacks the required role. 403.

View Source
var ErrObjectsNotFound = errors.New("admin s3 objects: object not found")

ErrObjectsNotFound — the named object does not exist. 404.

View Source
var ErrObjectsNotLeader = errors.New("admin s3 objects: not leader")

ErrObjectsNotLeader — this node is not the Raft leader for the S3 group. 503 + Retry-After: 1; AdminForward will catch this once the object surface is wired through it.

View Source
var ErrObjectsUploadTooLarge = errors.New("admin s3 objects: upload exceeds cap")

ErrObjectsUploadTooLarge — PUT body exceeded the per-object cap. Mapped to 413 payload_too_large; the bridge translates the adapter's ErrAdminUploadTooLarge to this.

View Source
var ErrObjectsValidation = errors.New("admin s3 objects: invalid request")

ErrObjectsValidation — empty / malformed key, malformed bucket name, oversized prefix/delimiter, invalid continuation token. 400.

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 AdminAttributeValue

type AdminAttributeValue struct {
	S    *string                        `json:"S,omitempty"`
	N    *string                        `json:"N,omitempty"`
	B    []byte                         `json:"B,omitempty"`
	BOOL *bool                          `json:"BOOL,omitempty"`
	NULL *bool                          `json:"NULL,omitempty"`
	SS   []string                       `json:"SS,omitempty"`
	NS   []string                       `json:"NS,omitempty"`
	BS   [][]byte                       `json:"BS,omitempty"`
	L    []AdminAttributeValue          `json:"L,omitempty"`
	M    map[string]AdminAttributeValue `json:"M,omitempty"`
}

AdminAttributeValue mirrors the adapter package's wire shape (S/N/B/BOOL/NULL scalars, SS/NS/BS sets, L/M containers). Defined in this package so the admin HTTP layer stays free of the adapter dependency; the main_admin.go bridge converts between this type and adapter.AdminAttributeValue field-for- field. Struct tags here are the wire-shape contract. MarshalJSON overrides them to preserve the empty-but-present L/M distinction (`{"L":[]}` and `{"M":{}}` are valid, type-bearing AttributeValue shapes in DynamoDB; the SPA needs both shapes to render correctly). UnmarshalJSON uses the default behaviour — a JSON `"L":[]` parses to a non-nil zero-length slice via the tag.

func (AdminAttributeValue) MarshalJSON

func (a AdminAttributeValue) MarshalJSON() ([]byte, error)

MarshalJSON preserves the kind distinction between "no L/M field" (nil slice/map) and "empty L/M field" (non-nil zero-length slice/map). A stock json.Marshal with omitempty would collapse both into the same wire shape, losing the type tag for an explicitly-empty list or map — and an explicitly-empty list is a legitimate DynamoDB AttributeValue ({"L":[]}). Gemini medium on PR #813.

Mirrors the adapter package's AdminAttributeValue.MarshalJSON invariant in the same way, except this side does not enforce the "exactly-one-field" kind check (the bridge in main_admin.go calls into the adapter, which performs that validation as part of its own marshal-time depth/kind audit). Imposing the kind check here too would reject legitimate responses constructed by tests where a zero-value attribute is intentionally produced.

type AdminItem

type AdminItem struct {
	Attributes map[string]AdminAttributeValue `json:"attributes"`
}

AdminItem is one row as the SPA sees it.

type AdminListObjectsOptions

type AdminListObjectsOptions struct {
	Prefix            string `json:"prefix,omitempty"`
	Delimiter         string `json:"delimiter,omitempty"`
	ContinuationToken string `json:"continuation_token,omitempty"`
	MaxKeys           int    `json:"max_keys,omitempty"`
}

AdminListObjectsOptions controls one AdminListObjects call. Empty values pick the adapter-side defaults (MaxKeys clamps to [1, adminObjectListMaxKeysCap]).

type AdminObject

type AdminObject struct {
	Key          string    `json:"key"`
	Size         int64     `json:"size"`
	ContentType  string    `json:"content_type"`
	ETag         string    `json:"etag"`
	LastModified time.Time `json:"last_modified"`
	StorageClass string    `json:"storage_class"`
}

AdminObject is the metadata projection the SPA receives on list pages and on the GET-object header set. The wire shape mirrors adapter.AdminObject field-for-field; the bridge in main_admin.go converts between the two so internal/admin stays adapter-free.

type AdminObjectListing

type AdminObjectListing struct {
	Objects               []AdminObject `json:"objects"`
	CommonPrefixes        []string      `json:"common_prefixes,omitempty"`
	NextContinuationToken string        `json:"next_continuation_token,omitempty"`
}

AdminObjectListing is the JSON response shape for the list page. CommonPrefixes is omitempty so a flat listing (no delimiter) returns just `{"objects":[...]}`. NextContinuationToken is non-empty only when the underlying scan stopped early.

type AdminScanItemsOptions

type AdminScanItemsOptions struct {
	Limit          int                            `json:"limit,omitempty"`
	ExclusiveStart map[string]AdminAttributeValue `json:"exclusive_start,omitempty"`
}

AdminScanItemsOptions / Result mirror the adapter package's shapes for the scan / continuation flow. ExclusiveStart is the continuation token a previous page surfaced as LastEvaluatedKey; the SPA passes it back verbatim via base64-url in the next request's ?next_cursor query.

type AdminScanItemsResult

type AdminScanItemsResult struct {
	Items            []AdminItem                    `json:"items"`
	LastEvaluatedKey map[string]AdminAttributeValue `json:"last_evaluated_key,omitempty"`
}

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

	// AdminListObjects returns a bounded page of objects under a
	// prefix. Delimiter folds pseudo-folders into CommonPrefixes
	// (only emitted when Options.Delimiter is set).
	AdminListObjects(ctx context.Context, principal AuthPrincipal, bucket string, opts AdminListObjectsOptions) (AdminObjectListing, error)
	// AdminGetObject streams one object's body + metadata. The caller
	// MUST close the returned body to release the read-tracker pin.
	// (body, meta, nil) on success; (nil, AdminObject{}, err) on any
	// failure. ErrObjectsNotFound flags the object-absent case.
	AdminGetObject(ctx context.Context, principal AuthPrincipal, bucket, key string) (io.ReadCloser, AdminObject, error)
	// AdminPutObject creates-or-replaces one object from a streaming
	// body. ContentType is the operator-supplied Content-Type header
	// (empty falls back to the adapter's defaultS3ContentType).
	// Returns ErrObjectsUploadTooLarge if the body exceeds the admin
	// per-object cap.
	AdminPutObject(ctx context.Context, principal AuthPrincipal, bucket, key string, body io.Reader, contentType string) error
	// AdminDeleteObject removes one object. Idempotent — a missing
	// object surfaces as success (mirrors the SigV4 DeleteObject
	// contract). Returns ErrObjectsBucketNotFound when the bucket
	// itself is absent.
	AdminDeleteObject(ctx context.Context, principal AuthPrincipal, bucket, key 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 CapabilityFanoutResult

type CapabilityFanoutResult struct {
	Verdicts []CapabilityVerdict
	OK       bool
}

CapabilityFanoutResult is the aggregated outcome. OK is true iff every verdict has Reachable && EncryptionCapable — there is no partial-success mode per §4.3.

Named CapabilityFanoutResult rather than the design-doc's `FanoutResult` to avoid a collision with the unrelated `admin.FanoutResult` in `keyviz_fanout.go` (KeyViz cluster fan-out shipped earlier in the same package).

func CapabilityFanout

func CapabilityFanout(
	ctx context.Context,
	routes RouteSnapshot,
	dial DialFunc,
	timeout time.Duration,
) (CapabilityFanoutResult, error)

CapabilityFanout fans GetCapability out to every unique (voter ∪ learner) of every group in routes. Concurrent; bounded by timeout regardless of how many members respond. Missing responses surface as Reachable=false verdicts (no partial-success mode — see §4.3).

Dedup key: FullNodeID. A node serving multiple groups is probed exactly once. Members with FullNodeID=0 are treated as distinct dedup keys per unique address; this case appears in stub catalogs where the dedup-by-id contract has not been satisfied — the helper still completes by falling back to address-based identity rather than silently collapsing every zero-id row into one probe.

Returns (result, nil) on every input. The error slot is reserved for input validation failures (zero-member snapshot, etc.) so callers can keep their existing `err != nil → refuse` shape.

type CapabilityVerdict

type CapabilityVerdict struct {
	FullNodeID        uint64
	EncryptionCapable bool
	BuildSHA          string
	SidecarPresent    bool
	Reachable         bool
	Err               error
}

CapabilityVerdict is one node's per-call outcome. Reachable=false means the dial or the RPC timed out / failed at transport level — not a "no" answer from the peer. The cutover RPC handler treats both Reachable=false and EncryptionCapable=false as hard refusals per the §8 failure-modes table, but the verdict separates them so the operator-facing error message can name the precise reason.

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 DialFunc

type DialFunc func(ctx context.Context, address string) (pb.EncryptionAdminClient, func(), error)

DialFunc opens a connection to one node's admin endpoint and returns an EncryptionAdmin client plus a cleanup closure. The helper invokes the closure exactly once per successful dial, regardless of how the RPC subsequently resolved.

The 6D design says "DialFunc reuses the existing admin connection pool" — the concrete implementation will reach for whatever connection-pool helper the caller already holds (e.g. the `internal/admin.ForwardClient` connection pool for TLS-aware dials).

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: §9.1 canonical (raftGroupID, leaderTerm) dedupe; when two sources disagree within the same (group, term, column) we set Conflicts[column]=true (and the row-level OR Conflict) so the SPA can hatch the affected cell.

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 KeyVizHotKeysFanout

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

KeyVizHotKeysFanout aggregates this node's local hot-keys response with responses fetched from a static peer list. Mirrors the KeyVizFanout pattern: parallel per-peer fetch, cookie forwarding, anti-recursion via the keyVizFanoutPeerHeader, per-peer body cap, per-node status surfaced in the response.

Merge semantics differ from the matrix fan-out — see docs/design/2026_05_28_proposed_keyviz_hot_key_topk.md §6:

  • count per key: SUM across peers (responses already carry scaled-to-true estimates from buildHotKeysResponse).
  • error_bound: SUM (per-peer bounds compose additively for the sum-of-independent-estimates).
  • sample_rate: MAX (the coarsest peer dominates).
  • sampled_n: SUM (informational).
  • dropped_samples: SUM.
  • skipped_long_keys: SUM.
  • degraded: OR (any peer with drops > 0 OR skipped > 0).
  • approximate: OR — SS sketches are always approximate.
  • snapshot_at: MAX (latest publish across the cluster).
  • keys: re-sort by summed count, take top.

Reporting the per-peer error_bound as a MAX (rather than SUM) would understate the noise floor by up to a factor of peer_count and mislead any client using the bound as a confidence interval. Either degradation source alone (drops OR skips) is enough to flip degraded — including the case where a peer drained its queue cleanly but skipped a too-long key that would have been the cluster's hottest entry.

func NewKeyVizHotKeysFanout

func NewKeyVizHotKeysFanout(self string, peers []string) *KeyVizHotKeysFanout

NewKeyVizHotKeysFanout wires the aggregator. self is the local node's identity for the FanoutResult.Nodes entry. peers is the list of HTTP base URLs (or host:port shorthand) to query — typically the operator's --keyvizFanoutNodes list with the local entry filtered out at construction (see main_admin.go::buildKeyVizHotKeysFanout).

func (*KeyVizHotKeysFanout) Run

func (f *KeyVizHotKeysFanout) Run(ctx context.Context, params hotKeysParams, local hotKeyResponse, cookies []*http.Cookie) hotKeyResponse

Run merges local with peer responses and returns the combined hot-keys response plus per-node status. On an empty peer list Run echoes local with a FanoutResult 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.

Run never returns an error: peer-level failures surface in the FanoutResult; aggregation is best-effort. A peer that 503s with hotkeys_disabled (i.e. mixed-K) is recorded as ok=false with the status message and its contribution is simply omitted from the merge, matching design §6's "peers that don't sample omit their contribution".

func (*KeyVizHotKeysFanout) WithHTTPClient

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

WithHTTPClient swaps the HTTP client. Tests inject an httptest server's Client(). nil resets to the default.

func (*KeyVizHotKeysFanout) WithLogger

WithLogger overrides the slog destination so main can attach a component tag. Nil is a no-op.

func (*KeyVizHotKeysFanout) WithResponseBodyLimit

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

WithResponseBodyLimit overrides the per-peer JSON decode cap. Values <= 0 reset to the default.

func (*KeyVizHotKeysFanout) WithTimeout

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 KeyVizHotKeysHandler

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

KeyVizHotKeysHandler serves GET /admin/api/v1/keyviz/hotkeys.

The route is mounted on the admin auth chain (SessionAuth + audit) and further gated by the full-access role at the router — design §7 (privacy/auth) requires both because the response carries actual user key bytes. Returns 503 keyviz_disabled / hotkeys_disabled separately so the SPA can render a precise "feature off" message rather than a generic 404.

func NewKeyVizHotKeysHandler

func NewKeyVizHotKeysHandler(source KeyVizHotKeysSource) *KeyVizHotKeysHandler

func (*KeyVizHotKeysHandler) ServeHTTP

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

func (*KeyVizHotKeysHandler) WithClock

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

WithClock lets tests inject a deterministic SnapshotAt for the out-of-window check. Nil is a no-op.

func (*KeyVizHotKeysHandler) WithFanout

WithFanout enables cluster-wide aggregation (design §6). When the supplied fan-out is non-nil the handler also gates on the keyVizFanoutPeerHeader on the inbound request so a peer-originated fan-out call does not recursively fan out (matches the matrix fan-out's anti-recursion semantics in keyviz_handler.go).

func (*KeyVizHotKeysHandler) WithLogger

WithLogger overrides the slog destination so main.go can attach a component tag without changing the constructor signature. Nil is a no-op.

func (*KeyVizHotKeysHandler) WithSnapshotWindow

func (h *KeyVizHotKeysHandler) WithSnapshotWindow(d time.Duration) *KeyVizHotKeysHandler

WithSnapshotWindow overrides the default keyvizStep used for the out_of_snapshot_window 400 (design §5). Non-positive is treated as a no-op so the default stays in force.

type KeyVizHotKeysSource

type KeyVizHotKeysSource interface {
	HotKeysOptions() (enabled bool, capacity, sampleRate, maxKeyLen int)
	HotKeysSnapshot(routeID uint64) *keyviz.KeyvizHotKeysSnapshot
	SubBucketBoundsFor(routeID uint64, subBucket int) (lo, hi []byte, ok bool)
}

KeyVizHotKeysSource is the narrow contract the hot-keys drill-down handler depends on. Production wires a *keyviz.MemSampler; tests use a stub that returns canned snapshots / bounds.

HotKeysSnapshot must be safe to call concurrently with the aggregator's publish path (atomic.Pointer.Load — see keyviz/hot_keys.go). SubBucketBoundsFor returns ok=false when the route is unknown, an aggregate, or the index is out of range; nil hi means unbounded tail.

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"`
	// Conflicts[j] is true when fan-out merge saw ≥2 sources report
	// different non-zero values for the same
	// (bucket, raft_group_id, leader_term, column j) tuple, so the SPA
	// can hatch the individual cell rather than the whole row. Allocated
	// lazily and only on the write path: nil whenever the row had no
	// conflict (single-node, no fan-out, legacy server, or a cleanly
	// merged row) so omitempty keeps it off the wire; otherwise
	// len == len(Values). Conflict is the OR of this slice.
	Conflicts []bool `json:"conflicts,omitempty"`
	// RaftGroupIDs[j] and LeaderTerms[j] carry the route's Raft
	// identity at the time column j was flushed (parallel to
	// Values[]). Phase 2-C+ fan-out uses
	// (bucket_id, raft_group_id, leader_term, column) as the
	// dedupe key. Per-cell representation is required because
	// leadership can flip within the requested window; row-level
	// scalars would only capture the first column's identity and
	// cause incorrect dedupe for later columns (Gemini HIGH on
	// PR #720). Zero values mean "term not tracked" — the
	// aggregator falls back to the legacy max-merge for those
	// cells. The slices are either nil (legacy server, no
	// per-column identity to share) or len == len(Values).
	RaftGroupIDs []uint64 `json:"raft_group_ids,omitempty"`
	LeaderTerms  []uint64 `json:"leader_terms,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 at least one of the row's cells observed a merge disagreement. For writes the predicate fires when ≥2 sources reported different non-zero values for the SAME (bucket, raft_group_id, leader_term, column) tuple — a Raft invariant violation (at most one leader per term per group). It is the OR of Conflicts[] and stays on the wire as the coarse signal an older SPA hatches the whole row from; newer clients prefer the per-cell Conflicts slice. Phase 2-C+ PR-3c upgraded the merge from the Phase 2-C row-level §4.2 max-merge to the canonical §9.1 per-cell (group, term)-keyed dedupe + sum-across-terms; PR-3d surfaces that per-cell conflict bit on the wire via Conflicts[].

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(ctx context.Context) 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(ctx context.Context) 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(ctx context.Context) 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 PeekMessageOptions

type PeekMessageOptions struct {
	Limit        int
	Cursor       string
	BodyMaxBytes int
}

PeekMessageOptions controls a peek call. Field defaults match the adapter's documented bounds (Limit clamped to [1, 100] with 0 meaning "default of 20"; BodyMaxBytes clamped to [256, 262144] with 0 meaning "default of 4096"; Cursor empty means "start from the front").

type PeekResult

type PeekResult struct {
	Messages   []PeekedMessage `json:"messages"`
	NextCursor string          `json:"next_cursor,omitempty"`
}

PeekResult is the admin-package projection of the adapter's AdminPeekQueue 3-tuple return (rows, nextCursor, error) bundled into one value so QueuesSource's method signatures stay regular. The handler renders this directly as the wire JSON.

type PeekedAttribute

type PeekedAttribute struct {
	DataType    string `json:"data_type"`
	StringValue string `json:"string_value,omitempty"`
	BinaryValue []byte `json:"binary_value,omitempty"`
}

PeekedAttribute mirrors the typed SQS MessageAttribute shape so binary payloads and the DataType discriminator survive the round trip to the SPA.

type PeekedMessage

type PeekedMessage struct {
	MessageID        string                     `json:"message_id"`
	Body             string                     `json:"body"`
	BodyTruncated    bool                       `json:"body_truncated"`
	BodyOriginalSize int64                      `json:"body_original_size"`
	SentTimestamp    time.Time                  `json:"sent_timestamp"`
	ReceiveCount     int32                      `json:"receive_count"`
	GroupID          string                     `json:"group_id,omitempty"`
	DeduplicationID  string                     `json:"deduplication_id,omitempty"`
	Attributes       map[string]PeekedAttribute `json:"attributes,omitempty"`
}

PeekedMessage is one row in the peek response. JSON tags pin the snake_case wire shape the design doc §3.5 specifies.

type PurgeInProgressError

type PurgeInProgressError struct {
	RetryAfter time.Duration
}

PurgeInProgressError is the typed admin error returned when the queue's 60-second PurgeQueue rate limit is active. RetryAfter carries the remaining wall-clock duration the caller should wait, derived from the same LastPurgedAtMillis value the adapter's rate-limit check observed inside its OCC read.

func (*PurgeInProgressError) Error

func (e *PurgeInProgressError) Error() string

func (*PurgeInProgressError) Is

func (e *PurgeInProgressError) Is(target error) bool

Is lets errors.Is(err, ErrQueuesPurgeInProgress) match any *PurgeInProgressError so the handler can branch on the sentinel while errors.As pulls the typed duration.

type PurgeResult

type PurgeResult struct {
	GenerationBefore uint64 `json:"generation_before"`
	GenerationAfter  uint64 `json:"generation_after"`
}

PurgeResult carries the committed-OCC generation pair so the admin handler's audit line records the value that actually landed (separate before/after meta reads would race a concurrent purge). JSON tags are pinned even though the current Phase 4 handler returns 204 with no body — the Phase 5 audit log will record the generation pair and a future wire encoder needs the snake_case shape (Claude r1 on PR #797 flagged the pre-emptive fix).

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"`
	// True when another queue's RedrivePolicy points at this one.
	IsDLQ bool `json:"is_dlq"`
	// Source queue names that point at this DLQ, sorted lex.
	DLQSources []string `json:"dlq_sources,omitempty"`
}

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
	// AdminSetQueueAttributes updates SQS queue attributes through the
	// same validator as the public SQS SetQueueAttributes path. The
	// SPA uses this for DLQ configuration (RedrivePolicy and
	// RedriveAllowPolicy).
	AdminSetQueueAttributes(ctx context.Context, principal AuthPrincipal, name string, attrs map[string]string) error
	// AdminPeekQueue returns a non-destructive sample of currently-
	// visible messages on the queue (read role required). Wired only
	// when the source supports it; the bridge in main_admin.go
	// translates between adapter and admin types so the admin package
	// stays free of the adapter dependency tree.
	AdminPeekQueue(ctx context.Context, principal AuthPrincipal, name string, opts PeekMessageOptions) (PeekResult, error)
	// AdminPurgeQueue is the SigV4-bypass purge counterpart to
	// AdminDeleteQueue: bumps the queue's generation so every
	// message becomes unreachable, leaving the queue itself in place.
	// Returns the committed generation pair so the audit log records
	// the value that actually landed.
	AdminPurgeQueue(ctx context.Context, principal AuthPrincipal, name string) (PurgeResult, 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 / AdminPeekQueue / AdminPurgeQueue return 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) AllowsRead

func (r Role) AllowsRead() bool

AllowsRead reports whether the role may execute sensitive read operations that surface payload content (e.g. SQS AdminPeekQueue, which exposes message bodies and attributes). Both RoleReadOnly and RoleFull satisfy the gate; the zero value (unauthenticated / role-less principal) does not. List / Describe endpoints use the looser session-auth gate because their output is metadata; peek diverges because the payload is the stored message itself.

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 RouteGroup

type RouteGroup struct {
	GroupID  uint64
	Voters   []RouteMember
	Learners []RouteMember
}

RouteGroup is one Raft group's membership. Voters and Learners are kept separate at the input level so the cutover RPC handler can log them distinctly, but CapabilityFanout treats them identically per the §4.1 contract "every (voter ∪ learner) of every Raft group". The 6D design pins that learner unreachability is a hard no the same way voter unreachability is — see §8 / row "One learner unreachable during fan-out".

type RouteMember

type RouteMember struct {
	FullNodeID uint64
	Address    string
}

RouteMember is one peer entry in a Raft group. The fan-out helper dials Address and identifies the node by FullNodeID for dedup across groups (a node serving multiple groups is probed once).

type RouteSnapshot

type RouteSnapshot struct {
	Groups []RouteGroup
}

RouteSnapshot is the input the cutover RPC handler builds from the Raft engine's membership view and passes to CapabilityFanout. Independent of the route-catalog `distribution.CatalogSnapshot`, which only carries shard→group mappings, not per-group membership.

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}, /buckets/{name}/acl, /buckets/{name}/objects, and /buckets/{name}/objects/{key-b64url}.

EscapedPath() is the authoritative input — net/http pre-decodes the raw path into r.URL.Path, turning `%2F` into a literal `/` and splitting a single bucket segment across the route. The confused-deputy class: a URL like `/buckets/victim%2Fobjects/{key}` would (without EscapedPath) route to bucket "victim" rather than the literal-named "victim/objects" segment, executing the requested op against the wrong bucket. servePerBucket operates on the escaped form so the encoded-slash and encoded-dot traversal classes stay closed across all four sub-trees (Codex P1 on PR #814).

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

	// KeyVizHotKeys backs the per-cell Top-K drill-down handler at
	// /admin/api/v1/keyviz/hotkeys (design 2026_05_28_proposed_keyviz_hot_key_topk).
	// Optional: nil makes the route 503 keyviz_disabled, the same shape
	// as the matrix route's disabled response. A non-nil source whose
	// HotKeysOptions() reports enabled=false serves 503 hotkeys_disabled
	// instead, so the SPA can distinguish "keyviz off entirely" from
	// "hot-keys drill-down off".
	KeyVizHotKeys KeyVizHotKeysSource

	// KeyVizHotKeysFanout enables cluster-wide aggregation of the
	// hot-keys drill-down endpoint (design §6). When non-nil, the
	// hot-keys handler merges the local response with peer responses
	// before serving. Optional: leaving it nil preserves the single-
	// node behaviour. Typically constructed from the same
	// --keyvizFanoutNodes operator flag as the matrix fan-out.
	KeyVizHotKeysFanout *KeyVizHotKeysFanout

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

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

	// Item-level admin RPCs (Phase 3a — design §3.1.1).
	// The handler in dynamo_items_handler.go drives these for the
	// /tables/{name}/items{,/{key}} sub-tree. Implementations live
	// in main_admin.go (dynamoTablesBridge) and bridge to the
	// adapter package's Admin*Item methods.
	AdminScanItems(ctx context.Context, principal AuthPrincipal, table string, opts AdminScanItemsOptions) (AdminScanItemsResult, error)
	AdminGetItem(ctx context.Context, principal AuthPrincipal, table string, key map[string]AdminAttributeValue) (*AdminItem, bool, error)
	AdminPutItem(ctx context.Context, principal AuthPrincipal, table string, item AdminItem) error
	AdminDeleteItem(ctx context.Context, principal AuthPrincipal, table string, key map[string]AdminAttributeValue) 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