Documentation
¶
Overview ¶
Package auth is the Harbor Protocol's JWT validation surface — the Phase 61 transport-edge cryptographic identity check that turns the Phase 60 wire transports' trust-based identity carriers into verified ones (RFC §5.5: "JWT, asymmetric algorithms only ... the triple (tenant, user, session) is in the JWT claims; the Protocol rejects any request without an identity scope").
The two-piece surface ¶
auth ships two pieces that compose:
Validator (this file) — transport-agnostic. Takes a raw JWT string, parses + verifies it against a configured KeySet, asserts the signing algorithm is in the asymmetric allowlist, extracts the (tenant, user, session) claim triple + scope claims, and returns a Verified struct. Every failure is one of the eight typed sentinels.
Middleware (middleware.go) — net/http binding. Reads the `Authorization: Bearer <token>` header, calls Validator.Validate, injects the verified identity + scopes into r.Context() (via identity.With + auth.WithScopes), and calls the wrapped handler. A failure writes a JSON Protocol error body with HTTP 401 (or 403 for a scope mismatch) and emits an audit-redacted slog.Warn.
The asymmetric-algorithm allowlist (CLAUDE.md §7 rule 1) ¶
Six algorithms — three RSA + three ECDSA — are accepted:
RS256 / RS384 / RS512 ECDSA-P-256/384/521 = ES256 / ES384 / ES512
HS* (HMAC) and `none` are rejected at the **parser level** via `jwt.WithValidMethods`, BEFORE the Keyfunc is consulted — so the classical algorithm-confusion CVE family (an `HS256` token signed with an `RS256` public key as the HMAC secret) is structurally impossible. The security_test.go suite pins this.
The Protocol identity claim shape ¶
JWT claims map onto identity.Identity by name:
{
"iss": "https://idp.example.com", // optional, audited
"sub": "user-12345", // optional, audited
"aud": "harbor-runtime", // optional, validated
"exp": 1746662400, // mandatory
"nbf": 1746576000, // optional
"tenant": "tenant-acme", // mandatory
"user": "user-12345", // mandatory
"session":"sess-01HX...", // mandatory
"scopes": ["admin", "console:fleet"] // optional
}
The triple (tenant, user, session) is mandatory — a missing claim returns ErrIdentityClaimMissing, which the middleware surfaces as a 401 with the canonical CodeIdentityRequired Protocol code. Scopes are optional — a token with no scopes is still authenticated, just not entitled to elevated subscriptions.
Concurrent reuse (D-025) ¶
Validator is a compiled artifact: the KeySet, the parser configuration, the clock, and the redactor are set once at construction and never mutated. Validate holds no per-call state on the struct — every per-call value lives on the function's stack / the returned Verified. One Validator is safe to share across N concurrent Validate goroutines; concurrent_test.go pins N≥120 under -race.
Index ¶
- Constants
- Variables
- func HasScope(ctx context.Context, s Scope) bool
- func IsValidScope(s Scope) bool
- func Middleware(v Validator, opts ...MiddlewareOption) func(http.Handler) http.Handler
- func SSEAccessTokenShim(next http.Handler) http.Handler
- func WithScopes(ctx context.Context, scopes []Scope) context.Context
- type AdminScopeUsedPayload
- type AuthRejectedPayload
- type AuthRotateTokenPayload
- type IdentityTriple
- type KeySet
- type MiddlewareOption
- type Option
- type RotateOption
- type RotateSurface
- type Scope
- type TokenIssuer
- type Validator
- type Verified
Constants ¶
const AdminImpersonationReason = "impersonation"
AdminImpersonationReason is the stable sentinel name for an `audit.admin_scope_used` event emitted by the Phase 72b admin-impersonation path. The Reason field of AdminScopeUsedPayload is set to this constant when the bus event comes from the impersonation gate (vs. the Phase 05 events.Subscribe admin-filter emit, which carries the events.AdminScopeUsedPayload shape).
Other emit sites under audit.admin_scope_used MAY add new sentinels (e.g. delegated-impersonation post-V1); a Protocol client branches on Reason, never on the wrapped human-readable detail.
const EventTypeAuthRejected events.EventType = "auth.rejected"
EventTypeAuthRejected is the canonical EventType emitted whenever the Phase 61 JWT auth pipeline rejects a request at the transport edge (a missing token, an algorithm-confusion attack, an expired bearer, an unknown kid, a scope mismatch, etc.). The event lives on the bus alongside every other rejection-class signal — the same observability surface Phase 05 + Phase 57 ship for the rest of the runtime — so a Console (or any Protocol client) can subscribe to auth rejections through the canonical events.EventBus rather than scraping slog output.
Payload is AuthRejectedPayload; the body NEVER carries the raw token (CLAUDE.md §7 rule 7), only the reason sentinel name, the `kid` (a public header), and the optional `iss` / `sub` audited identifiers — all run through the audit.Redactor at the middleware edge before the publish.
PR #91 / D-082: surfaced by the Wave 10 audit's WARN-3. Before this addition, auth rejections only emitted a structured `slog.Warn` — observable to an operator with log access but NOT to a Console subscribing through the Protocol's canonical event channel.
const HeaderSession = "X-Harbor-Session"
HeaderSession is the per-request session selector (D-171). The connection token authenticates the (tenant, user, scopes) — it is a per-backend credential, like an API key, NOT a single-session pin. The session is chosen per-conversation by the client and supplied on every request via this header. When present, the middleware REPLACES the token's `session` claim with the header value (keeping the token's verified tenant + user), so one connection drives many isolated sessions. The token's `session` claim is a DEFAULT only: when the header is absent, the claim's session is used.
The value MUST be identical to the SSE transport's `stream.HeaderSession`; the constant is duplicated here (rather than imported) because `stream` imports `auth` and the reverse would be an import cycle.
Variables ¶
var ( // ErrTokenMissing — the request carried no JWT (the Authorization // header was absent or empty). Mapped onto CodeIdentityRequired // (HTTP 401) by the middleware. ErrTokenMissing = errors.New("auth: token missing") // ErrTokenMalformed — the JWT was not a valid three-segment string // or could not be base64-decoded. Mapped onto CodeAuthRejected // (HTTP 401). ErrTokenMalformed = errors.New("auth: token malformed") // ErrAlgNotAllowed — the JWT's `alg` header was not in the // asymmetric allowlist (HS*, `none`, or anything else). The parser // rejects this BEFORE the Keyfunc is consulted, so an algorithm- // confusion attack is structurally impossible. Mapped onto // CodeAuthRejected (HTTP 401). ErrAlgNotAllowed = errors.New("auth: signing algorithm not in asymmetric allowlist") // ErrSignatureInvalid — the JWT's signature did not verify against // the resolved key. Mapped onto CodeAuthRejected (HTTP 401). ErrSignatureInvalid = errors.New("auth: signature invalid") // ErrTokenExpired — the JWT's `exp` claim is in the past relative // to the validator's clock. Mapped onto CodeAuthRejected (HTTP 401). ErrTokenExpired = errors.New("auth: token expired") // ErrTokenNotYetValid — the JWT's `nbf` claim is in the future // relative to the validator's clock. Mapped onto CodeAuthRejected // (HTTP 401). ErrTokenNotYetValid = errors.New("auth: token not yet valid") // ErrUnknownKey — the JWT's `kid` header did not resolve to a // public key in the configured KeySet. Mapped onto // CodeAuthRejected (HTTP 401). ErrUnknownKey = errors.New("auth: kid does not resolve in key set") // ErrIdentityClaimMissing — the JWT verified but its claims did not // carry the mandatory (tenant, user, session) triple. Mapped onto // CodeIdentityRequired (HTTP 401) — RFC §5.5: "the Protocol rejects // any request without an identity scope." ErrIdentityClaimMissing = errors.New("auth: identity claim missing") // ErrAudienceMismatch — the JWT's `aud` claim did not match the // validator's configured audience (when WithAudience was supplied). // Mapped onto CodeAuthRejected (HTTP 401). ErrAudienceMismatch = errors.New("auth: audience mismatch") // ErrIssuerMismatch — the JWT's `iss` claim did not match the // validator's configured issuer (when WithIssuer was supplied). // Mapped onto CodeAuthRejected (HTTP 401). ErrIssuerMismatch = errors.New("auth: issuer mismatch") )
Sentinel errors. Callers compare via errors.Is.
Each rejection path returns exactly one sentinel, wrapped with context — so a middleware mapping a Validate error onto a Protocol error code branches on the sentinel, not on the wrapped detail.
var ( // ErrRotateMisconfigured — NewRotateSurface was called with a nil // TokenIssuer or redactor. ErrRotateMisconfigured = stderrors.New("auth: rotate-token surface missing a mandatory dependency") // ErrRotateIdentityRequired — the request carried an incomplete // identity triple. Maps onto CodeIdentityRequired (HTTP 401). ErrRotateIdentityRequired = stderrors.New("auth: rotate-token identity scope incomplete") // ErrRotateScopeRequired — the caller lacks the verified // `admin` scope claim. Maps onto CodeIdentityScopeRequired (403). ErrRotateScopeRequired = stderrors.New("auth: rotate-token requires the verified `admin` scope claim") // ErrRotateIdentityMismatch — the request body's identity scope // disagreed with the verified-JWT identity. Maps onto // CodeIdentityRequired (HTTP 401) — defence-in-depth. ErrRotateIdentityMismatch = stderrors.New("auth: rotate-token body identity disagrees with the verified token") // ErrRotateIssueFailed — the TokenIssuer failed to mint a token. // Maps onto CodeRuntimeError (HTTP 500). ErrRotateIssueFailed = stderrors.New("auth: rotate-token issuer failed to mint a token") )
Rotation-surface sentinel errors. Callers (the wire handler) compare via errors.Is and map onto the canonical Protocol Code.
var AllowedAlgorithms = []string{
"RS256", "RS384", "RS512",
"ES256", "ES384", "ES512",
}
AllowedAlgorithms is the asymmetric-algorithm allowlist Harbor enforces (CLAUDE.md §7 rule 1). Six algorithms — three RSA-PKCS#1v1.5 (RS256/RS384/RS512) and three ECDSA (ES256/ES384/ES512). HS* and `none` are rejected at the parser level via jwt.WithValidMethods.
The list is exported (a) so tests pin it, (b) so an operator inspecting the binary can confirm the surface, (c) so a later phase adding a JWKS driver inherits the same list.
var ErrMisconfigured = errors.New("auth: NewValidator missing a mandatory dependency")
ErrMisconfigured — NewValidator was called with a nil KeySet. A validator without a key source cannot verify any token; fail closed rather than building one that rejects everything (CLAUDE.md §5).
Functions ¶
func HasScope ¶
HasScope reports whether ctx carries scope s. A request that has not been through the auth middleware (no scope set on ctx) returns false — the safe default for a privilege check is "absent = denied".
func IsValidScope ¶
IsValidScope reports whether s is one of the canonical scopes. An unknown scope on a JWT is silently dropped from the verified set — a token that claims "future:scope" reads back as having no scopes rather than failing. The closed set means an attacker cannot grant themselves an undocumented privilege by inventing a scope name.
func Middleware ¶
Middleware returns an http.Handler decorator that enforces JWT-bearer auth on every request.
The middleware:
- Reads the `Authorization: Bearer <token>` header. A missing or malformed header writes a 401 + CodeIdentityRequired Protocol error body and returns — `next` is never called.
- Calls Validator.Validate(ctx, token). A failure writes a 401 + the appropriate Protocol error code (CodeIdentityRequired for ErrIdentityClaimMissing / ErrTokenMissing; CodeAuthRejected for every other sentinel) and returns.
- On success, attaches the verified identity to r.Context() (via identity.With) and the verified scope set (via WithScopes), then calls next with the augmented request.
Middleware is a compiled artifact: every field is set once at construction and never mutated. The decorator is safe to share across N concurrent requests (D-025).
func SSEAccessTokenShim ¶
SSEAccessTokenShim returns an http.Handler decorator that promotes an `?access_token=<jwt>` URL query parameter to a synthesized `Authorization: Bearer <jwt>` header BEFORE delegating to the standard auth.Middleware. It is the wire-side counterpart of the Console's EventSource SSE-subscribe path (`web/console/src/lib/protocol/client.ts` — "the bearer token rides as `access_token` — `EventSource` cannot carry an `Authorization` header").
Why an SSE-only shim ¶
The standard browser `EventSource` API cannot set request headers (https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface) — there is no `Authorization` header path for an EventSource subscriber. The conventional workaround is the `access_token` URL query parameter (OAuth 2.0 bearer-token URI usage, RFC 6750 §2.3) for SSE endpoints only.
The shim is SSE-ONLY by design. Accepting the query parameter on every endpoint would leak the bearer token into:
- browser history,
- intermediary access logs (proxies, CDNs, runtime stderr access-log middleware),
- the Referer header on any embedded link,
- server-side request-dump panics.
Limiting the shim to the SSE endpoint scopes the leakage to the surface where EventSource has no alternative.
Behavior ¶
On a request with NO Authorization header AND a non-empty `access_token` query parameter, the shim clones the request, sets `Authorization: Bearer <access_token>` on the clone, and calls next with the clone. The original request — and the original query parameter — is not mutated; downstream code sees a synthesized Authorization header as if the client had supplied one. The query param is intentionally LEFT in the URL on the cloned request so the SSE handler's URL parsing (event-type filters, admin flag) sees the same shape it always did.
On a request that ALREADY has an Authorization header, the shim is a pass-through: an explicit Authorization header is always preferred, and a same-origin Console (or a non-browser SSE client) that already sets the header gets the standard contract.
On a request with neither Authorization nor access_token, the shim is a pass-through; the standard middleware rejects the request with CodeIdentityRequired as it always did.
Concurrent reuse (D-025) ¶
The shim wraps next once at construction and holds no mutable state; it is safe to share across N concurrent requests.
Round-3 walkthrough fix: pre-shim, the Console's cross-origin SSE subscribe got 401 on every request because the standard auth.Middleware only read Authorization. The CORS preflight pass (Phase 83v / D-162) unblocked the REST surface; this shim unblocks the SSE surface for the same multi-process Console+Runtime posture.
func WithScopes ¶
WithScopes attaches the verified scope set to ctx. The middleware calls this once per request after Validator.Validate succeeds; the SSE handler reads the set back via HasScope to gate cross-tenant fan-in.
A nil scopes slice is permitted (a token with no scopes is still authenticated) — HasScope will return false for any scope check.
Types ¶
type AdminScopeUsedPayload ¶
type AdminScopeUsedPayload struct {
events.SafeSealed
// Actor is the verified admin identity at the Protocol edge.
// V1 invariant: equals the JWT's verified `(tenant, user,
// session)` triple.
Actor IdentityTriple
// Requester is the originating admin identity. V1 invariant:
// equals Actor (single-hop impersonation only); diverges
// post-V1 for delegated-impersonation chains.
Requester IdentityTriple
// Impersonating is the target identity the run executes
// under. Complete `(tenant, user, session)` triple — missing
// components fail loudly at the Protocol edge with
// CodeIdentityRequired.
Impersonating IdentityTriple
// Reason is the stable sentinel name (e.g.
// `AdminImpersonationReason = "impersonation"`).
Reason string
// Method is the Protocol method that carried the
// impersonation (e.g. methods.MethodStart,
// methods.MethodRedirect, methods.MethodUserMessage —
// canonical method names live in internal/protocol/methods).
Method string
}
AdminScopeUsedPayload is the typed payload on the `audit.admin_scope_used` canonical event when the emit source is an impersonation request (Phase 72b). The pre-existing emit site (the `events.Subscribe` admin-filter, Phase 05 / `internal/events/drivers/inmem`) continues to use `events.AdminScopeUsedPayload`; this richer typed payload is what the Phase 72b impersonation path publishes.
SafePayload by construction: every field is a bounded-string shaped identity component plus two enum strings. No caller-controlled bytes reach the bus — the wire shape rejects any deviation at the Protocol edge before reaching the emit.
Brief 11 §PG-5 verbatim names the three identity fields. The `Reason` field is the stable sentinel (`AdminImpersonationReason`); the `Method` field is the Protocol method that carried the impersonation (one of the ten canonical methods, typically `start` but `redirect` / `user_message` are accepted too — Phase 72b's non-goal explicitly names per-tool-call impersonation downgrade as post-V1, so the method stays one of the ten).
Phase 72b, D-107.
type AuthRejectedPayload ¶
type AuthRejectedPayload struct {
events.SafeSealed
// Reason is the stable sentinel name (e.g. "token_expired").
Reason string
// KID is the JWT's `kid` header when known. Empty when the
// rejection fired before the keyfunc was consulted.
KID string
// Issuer is the JWT's `iss` claim when known. Empty when the
// rejection fired before claim extraction.
Issuer string
// Subject is the JWT's `sub` claim when known. Empty when the
// rejection fired before claim extraction. The triple
// (tenant/user/session) is NEVER emitted on this payload — a
// rejected request never carries a verified identity, and
// echoing back unverified claims would let an attacker confirm
// which (tenant, user, session) triples are valid.
Subject string
}
AuthRejectedPayload is the wire-side audit body for the auth.rejected event. The fields mirror what `Validator.audit` already emits to slog, so a subscriber sees the same redacted surface a log-scraping operator sees.
Subject + Issuer are zero-value strings when the JWT was rejected before claim extraction (e.g. a malformed token / algorithm confusion). KID is zero-value when the rejection happened before the keyfunc was consulted (e.g. an `Authorization` header that didn't carry a Bearer scheme at all).
Reason is the stable sentinel name from `reasonForWire` — one of `token_missing` / `token_malformed` / `alg_not_allowed` / `signature_invalid` / `token_expired` / `token_not_yet_valid` / `unknown_key` / `identity_claim_missing` / `audience_mismatch` / `issuer_mismatch` / `verification_failed`. A Protocol client branches on Reason; never on the wrapped human-readable detail (which may include operator-specific data we deliberately do not echo to an unauthenticated caller).
type AuthRotateTokenPayload ¶
type AuthRotateTokenPayload struct {
events.SafeSealed
// Actor is the verified admin identity at the Protocol edge.
Actor identity.Identity
// Method is the Protocol method that carried the action.
Method string
}
AuthRotateTokenPayload is the typed SafePayload published on the canonical `audit.admin_scope_used` event when an operator rotates their token. Phase 73m / D-129.
SafePayload by construction: every field is a bounded identity component or a Protocol method name — the re-minted token itself is NEVER on the payload (CLAUDE.md §7 "never log secrets").
type IdentityTriple ¶
type IdentityTriple struct {
// Tenant / User / Session are the flattened `(tenant, user, session)`
// isolation triple the audit payload records — the wire-adjacent
// mirror of the runtime's identity quadruple (no Run: an audit row
// records the principal, not the per-execution scope).
Tenant string
User string
Session string
}
IdentityTriple is the flat audit-visible shape of an IdentityScope (no nested Actor / Requester / Impersonating — those collapse to their triple at the payload boundary). Used as the Actor / Requester / Impersonating field of AdminScopeUsedPayload so the audit shape is purely flat strings; no caller-controlled bytes reach the bus.
IdentityTriple is intentionally distinct from identity.Identity: the audit payload lives on the wire-adjacent bus surface, not on the runtime's identity-quadruple surface. Mirroring the runtime type 1:1 would couple the audit shape to internal storage refactors (the same anti-pattern RFC §5.1 names for the wire IdentityScope). Phase 72b, D-107.
type KeySet ¶
type KeySet interface {
// KeyByID returns the public key + the algorithm name for kid.
// alg MUST be one of AllowedAlgorithms; an alg outside the
// allowlist is treated as ErrUnknownKey by the Validator (the
// allowlist gate is at the parser, not the KeySet, but a KeySet
// that returned an HMAC key would be rejected here as well).
//
// Returning a wrapped ErrUnknownKey signals the kid is not known.
// Any other error is wrapped as ErrUnknownKey by the Validator.
KeyByID(kid string) (key crypto.PublicKey, alg string, err error)
}
KeySet maps a JWT `kid` header to the public key + algorithm name to verify the token's signature with. Implementations MUST be safe for concurrent reads — the Validator calls KeyByID on every Validate.
The static implementation suffices for V1 + the `harbor dev` dev-token use case. A later phase can ship a JWKS driver that auto-refreshes from a URL behind the same interface — additive, no reshape.
type MiddlewareOption ¶
type MiddlewareOption func(*middlewareConfig)
MiddlewareOption configures Middleware.
func MWLogger ¶
func MWLogger(l *slog.Logger) MiddlewareOption
MWLogger sets the slog.Logger the middleware logs rejection paths to. A nil logger keeps slog.Default(). The validator carries its own logger for the audit emit; this one logs the wire-side rejection (the chosen Protocol error code, the HTTP status).
type Option ¶
type Option func(*validatorConfig)
Option configures NewValidator.
func WithAudience ¶
WithAudience sets the expected JWT `aud` claim. A token whose `aud` claim does not contain the expected value is rejected with ErrAudienceMismatch. An empty configured audience disables the check.
func WithClock ¶
WithClock overrides the validator's clock — used by tests to drive expiration / nbf checks deterministically. A nil clock keeps the default (time.Now).
func WithEventBus ¶
WithEventBus wires an events.EventBus into the Validator so the audit emit on every rejection ALSO publishes a canonical `auth.rejected` event onto the bus (PR #91 amendment to D-079). The bus is OPTIONAL — when not supplied (or nil), rejections still emit a structured slog.Warn through the configured Redactor; the bus emit is an additive observability surface that lets a Console subscribe to auth rejections through the Protocol's canonical event channel rather than scraping logs.
Production wiring (the registry-path NewMux path) SHOULD inject the bus so the Console sees rejections; the test-only escape hatch (a Validator constructed without a bus) keeps the existing per-package tests pinning the slog-only contract.
func WithIssuer ¶
WithIssuer sets the expected JWT `iss` claim. A token whose `iss` claim does not match is rejected with ErrIssuerMismatch. An empty configured issuer disables the check.
func WithLogger ¶
WithLogger sets the slog.Logger the validator emits redacted audit records to. A nil logger keeps slog.Default().
func WithRedactor ¶
WithRedactor sets the audit.Redactor the validator runs audit payloads through before logging. The redactor is **mandatory** — NewValidator fails closed with ErrMisconfigured when this option is not supplied (CLAUDE.md §7 rule 6: "every payload goes through audit.Redactor"; CLAUDE.md §13 "Test stubs as production defaults on operator-facing seams"). A nil redactor is treated as "unsupplied" and is rejected by NewValidator the same way.
type RotateOption ¶
type RotateOption func(*RotateSurface)
RotateOption configures NewRotateSurface.
func WithRotateBus ¶
func WithRotateBus(b events.EventBus) RotateOption
WithRotateBus wires the events.EventBus the surface publishes the `audit.admin_scope_used` event onto on every successful rotation. OPTIONAL — when unwired, the rotation is logged at Info instead (never fully silent — CLAUDE.md §13). A nil bus is treated as "WithRotateBus not supplied".
func WithRotateLogger ¶
func WithRotateLogger(l *slog.Logger) RotateOption
WithRotateLogger sets the slog.Logger the surface logs to. A nil logger keeps slog.Default().
type RotateSurface ¶
type RotateSurface struct {
// contains filtered or unexported fields
}
RotateSurface is the transport-agnostic `auth.rotate_token` handler. It is built once per Runtime process via NewRotateSurface and shared across every Protocol request; Rotate is safe for concurrent use by N goroutines (D-025) — every field is set once at construction and never mutated.
func NewRotateSurface ¶
func NewRotateSurface(issuer TokenIssuer, redactor audit.Redactor, opts ...RotateOption) (*RotateSurface, error)
NewRotateSurface builds the `auth.rotate_token` surface. The TokenIssuer and the audit.Redactor are MANDATORY — a nil fails loud with ErrRotateMisconfigured rather than building a surface that would nil-panic or emit an unredacted audit payload (CLAUDE.md §5, §7 rule 6, §13).
The returned *RotateSurface is immutable after construction (D-025) and safe for concurrent use by N goroutines.
func (*RotateSurface) Rotate ¶
func (s *RotateSurface) Rotate(ctx context.Context, verified Verified, req types.AuthRotateTokenRequest) (*types.AuthRotateTokenResponse, error)
Rotate handles the `auth.rotate_token` method. `verified` is the caller's verified JWT identity + scopes (from auth.Middleware); `req` is the decoded wire request. The surface asserts the body identity against the verified identity, gates on the `admin` scope, re-mints the token, and emits the audit event.
Returns the wire response on success, or one of the package's typed sentinels on failure — the wire handler maps each onto a canonical Protocol Code.
type Scope ¶
type Scope string
Scope is a verified JWT scope claim — a privilege the Protocol consults when granting cross-session / cross-tenant subscriptions or fleet-control privileges (RFC §4.2 + §5.5: "Extended scopes (admin, console:fleet) gate cross-session and cross-tenant subscriptions").
Scopes are not isolation principals — the (tenant, user, session) triple is and stays the isolation key (CLAUDE.md §6 rule 1). A scope is an *additional* entitlement carried alongside the triple.
Canonical scope constants. The set is closed at V1 — adding a new scope is a Protocol-surface phase, not an ad-hoc addition.
ScopeAdmin is the cross-tenant fan-in entitlement: a Subscribe call with `events.Filter.Admin = true` requires this scope (RFC §6.13 admin subscriptions). The Phase 05 events.ErrAdminScopeRequired sentinel is the corresponding error.
ScopeConsoleFleet is the fleet-observation entitlement (RFC §7 "Fleet privilege tiers"): a Console managing multiple Runtimes uses this scope to subscribe to events from outside its single (tenant, user, session) triple. Distinct from a hypothetical "fleet:control" scope (deferred per D-066).
func CanonicalScopes ¶
func CanonicalScopes() []Scope
CanonicalScopes returns a copy of the closed canonical scope set. Used by tests to pin the surface and by the audit emitter to render the per-request scope set deterministically.
func ScopesFrom ¶
ScopesFrom returns the verified scope set on ctx, and a presence bool. A request that has not been through the auth middleware has no scopes attached — ScopesFrom returns (nil, false), which is distinct from ScopesFrom returning (nil, true) for a token with no scopes.
type TokenIssuer ¶
type TokenIssuer interface {
// IssueToken mints a fresh Bearer-shaped JWT for the supplied
// identity triple + scope set, expiring at `now + TTL`. The
// returned string is the raw token; expiresAt is its expiry, UTC.
// The caller has already verified the identity — IssueToken does
// not re-validate it.
IssueToken(ctx context.Context, id identity.Identity, scopes []Scope, now time.Time) (token string, expiresAt time.Time, err error)
}
TokenIssuer re-mints a Protocol-auth JWT for an already-verified identity. The V1 implementation is the `harbor dev` ephemeral signer; a post-V1 release-engineering phase fits an OIDC token-exchange issuer (RFC 8693) behind the same shape.
An implementation MUST be safe for concurrent use by N goroutines (D-025) — RotateSurface shares one issuer across every request.
type Validator ¶
type Validator interface {
// Validate parses + verifies the rawToken JWT and returns the
// extracted identity + scopes. Every error wraps one of the
// package's typed sentinels — callers compare via errors.Is.
Validate(ctx context.Context, rawToken string) (Verified, error)
}
Validator is the JWT validation surface. Construct via NewValidator; do not construct directly. One Validator is safe to share across N concurrent Validate goroutines (D-025).
func NewValidator ¶
NewValidator builds a JWT Validator over the supplied KeySet.
Both the KeySet AND an audit.Redactor (via WithRedactor) are mandatory. A nil KeySet — or omission of WithRedactor — fails loud with ErrMisconfigured rather than building a validator that would reject every token (KeySet) or log raw payloads unredacted (Redactor; CLAUDE.md §7 rule 6 — "every payload goes through audit.Redactor" — and CLAUDE.md §13 "Test stubs as production defaults on operator-facing seams"). Production callers wire `audit/drivers/patterns.New()` as the redactor; tests wire a real or test-local Redactor via the `auth_test` package or a _test.go-local stub.
The returned Validator is immutable after construction (D-025) and safe for concurrent use by N goroutines.
type Verified ¶
type Verified struct {
// Identity is the (tenant, user, session) triple extracted from the
// JWT's mandatory claims. Validates clean against identity.Validate
// — the Validator already ran that check.
Identity identity.Identity
// Scopes is the verified scope set the JWT carried. May be empty;
// a token with no scopes is still authenticated, just not entitled
// to any elevated subscription. Membership is checked via
// auth.HasScope.
Scopes []Scope
// Subject is the JWT's `sub` claim, if present. Audited; never used
// as an isolation principal (the triple is the isolation key).
Subject string
// Issuer is the JWT's `iss` claim, if present. Audited.
Issuer string
}
Verified is the result of a successful Validate call.