Documentation
¶
Overview ¶
Role-gated space authorization (remediation R3).
Presence in a Space's grants is not authority: a grant carries a permission set, may have lapsed, and may belong to a group rather than a user. Every space-scoped route therefore states the minimum role it needs and is refused below it.
Role table (phase-2-auth-spaces.md):
viewer status, schedule, runs, notifications, config (read), snapshots,
recovery-envelope retrieval, restore into the Space (decisions.md #7)
editor run backup now, change config/schedule/retention
manager key setup, Recovery-Key rotation
Group grants are resolved against the graph API with the caller's own bearer token, and only when the caller's direct grant does not already reach the required role — so the common case costs no upstream call.
Admin detection and the admin-only route gate (phase-2 deliverable 1; decisions.md #13/#15).
Admin status grants ONLY target/grant management — never plaintext, never cross-space backup access (decisions.md #15). This file resolves whether the caller is an OpenCloud admin and gates /api/v1/admin/* behind that.
Mechanism (pinned by the admin-role spike, phase-0-findings.md): the OIDC access token carries no role claim on OpenCloud 7.3.0, so admin status is resolved via the graph API — GET /graph/v1.0/me?$expand=appRoleAssignments — mapping the Admin app-role id. A config allow-list resolver is provided as the decisions.md #13 fallback.
Package api holds the HTTP handlers and DTOs for the backup service, built on the standard library net/http ServeMux (Go 1.22+ method+path patterns) — no third-party router, keeping the single-static-binary goal (decisions.md success criterion 1).
Phase 2 wires the first real end-to-end slice: browser token -> OIDC middleware -> CS3 gateway -> JSON. Authorization is enforced server-side: a user sees only Spaces they are a member of and only backup targets granted to them (phase-2 deliverable 4); client-supplied ids are never trusted.
Backup configuration and run endpoints (phase-4 deliverable 4).
Authorization rules enforced here:
- Every route is space-scoped and checks CS3 membership server-side; a non-member gets 403 and learns nothing about the space.
- A Space may only be bound to a target that is *granted* to the caller. The grant check runs server-side via targets.Authorizer; a client-supplied target id is never trusted (decisions.md #12).
- Responses carry configuration and run metadata only. No key material, no target credentials, no endpoint or bucket (decisions.md #14).
Shared access to the OpenCloud graph API's /me document.
The OIDC access token on OpenCloud 7.3.0 carries neither a role claim nor a groups claim (phase-0-findings.md, admin-role spike), so both admin status (decisions.md #13) and group membership (remediation R3) have to be read from the graph API with the caller's own bearer token. One fetcher serves both.
Backup key setup endpoints (phase-3 deliverable 2 & 3).
Trust invariants enforced here (decisions.md, AGENTS.md):
- The plaintext Recovery Key NEVER crosses the network. The browser generates the RK, generates the DK, wraps the DK under the RK, and sends the RK-wrapped envelope plus the raw DK. The DK does cross the wire, once, at setup, over TLS: the server has to see it to produce the SRW wrap that unattended runs need (decisions.md #1 — this is not zero-knowledge). It is held for the length of that request and zeroized.
- The server adds the SRW wrap so unattended runs are possible (decisions.md #1) and persists both envelopes. It stores no plaintext.
- Key material never appears in a response, an error message, or a log line. Responses carry status metadata only.
- Every space-scoped route checks CS3 membership server-side; a non-member gets 403 and learns nothing about the space.
OIDC bearer-token authentication for the backup API.
The middleware validates access tokens issued by the OpenCloud IDP (Konnect) statelessly, per request: it fetches the issuer's JWKS (discovered from issuer metadata) and verifies the token signature, issuer, audience, and expiry. There are no server-side sessions of our own (phase-2 deliverable 1).
On 7.3.0 the access token carries no role/group claim (phase-0-findings.md, admin-role spike), so admin status is resolved separately (see admin.go), not from the token.
Restore endpoints (Path B, phase-5 deliverable 3).
Authorization rules enforced here:
- Both routes are space-scoped and check CS3 membership server-side. Being an OpenCloud admin grants nothing: an admin who is not a member of the Space gets the same 403 as any stranger (decisions.md #2, #15). There is deliberately no admin variant of these endpoints.
- A restore never overwrites live data; it lands in "Restore/<timestamp>/" (decisions.md #3). That is the worker's job, not the client's: the client cannot choose a destination.
- Responses carry snapshot and job metadata only — no key material, no target credentials, no file names.
Schedule, status and notification endpoints (phase-6 deliverable 3).
Authorization is the same rule as everywhere space-scoped: CS3 membership, checked server-side, with no admin variant. An OpenCloud admin who is not a member of the Space gets the same 403 as a stranger — the admin is a configuration actor, not a data actor (decisions.md #15), and a Space's backup status is data about that Space.
Responses carry schedules, states, counts and timestamps. Never key material, never target credentials, never endpoints, buckets or file names.
Resolving the caller's OpenCloud user id.
The OIDC `sub` claim is NOT the OpenCloud user id. Measured on the 7.3.0 fixture with a real browser login: `sub` is an opaque identifier minted by the built-in IdP, while graph `/me` answers with the user's OpenCloud id — the id CS3 grants, Space ownership and the graph user search all use. An external IdP (Keycloak, Pocket ID, ...) has its own `sub` again, mapped by OpenCloud's proxy to the same OpenCloud user.
So every authorization decision that names a user — Space membership, target grants, the admin allow-list — is taken on the id graph `/me` returns, asked with the caller's own token. OpenCloud has already done the claim mapping for its own purposes; asking it is the one answer that agrees with it for every IdP it supports.
Index ¶
- Constants
- Variables
- func IsAdminFrom(ctx context.Context) bool
- func WithResponseHeaders(next http.Handler) http.Handler
- type AdminResolver
- type AdminResolverFunc
- type AllowlistAdminResolver
- type Env
- type GraphAdminResolver
- type GraphGroupResolver
- type GraphUserResolver
- type GroupResolver
- type GroupResolverFunc
- type Identity
- type KeySet
- type OIDCConfig
- type OIDCEnv
- type OpenCloudEnv
- type Option
- func WithAdminResolver(r AdminResolver) Option
- func WithAuthorizer(a targets.Authorizer) Option
- func WithBackupRunner(r backupRunner) Option
- func WithBuildInfo(info buildinfo.Info) Option
- func WithClock(now func() time.Time) Option
- func WithCredSealer(c targets.CredSealer) Option
- func WithGroupResolver(r GroupResolver) Option
- func WithJobStore(st jobs.Store) Option
- func WithKeyStore(st keys.Store) Option
- func WithLogger(l *slog.Logger) Option
- func WithNotificationStore(n notificationReader) Option
- func WithOpenCloudVersion(v openCloudVersion) Option
- func WithReadiness(fn func(context.Context) error) Option
- func WithRestoreRunner(r restoreRunner) Option
- func WithSRWWrapper(w srwWrapper) Option
- func WithScheduleAdvisor(a scheduleAdvisor) Option
- func WithSpaceConfigStore(st spacecfg.Store) Option
- func WithSpaceReader(r cs3.SpaceReader) Option
- func WithStaleRule(r notify.StaleRule) Option
- func WithTargetChecker(c objstore.Checker) Option
- func WithTargetStore(st targets.Store) Option
- func WithTokenValidator(v TokenValidator) Option
- func WithUserResolver(r UserResolver) Option
- type Server
- type TokenValidator
- type UserResolver
- type UserResolverFunc
Constants ¶
const APILevel = 1
APILevel is the version of /api/v1 that the service and the web bundle agree on (compatibility-policy.md §2). Raise it with any change an older or newer bundle would misread: a removed or renamed field or route, a changed meaning. Additions an older bundle ignores do not raise it.
The bundle carries the same number (web/src/api/apilevel.ts); a test fails when the two differ. A bundle that finds a different level warns its users, because its requests may fail.
const DefaultAdminAppRoleID = "71881883-1768-46bd-a24d-a356a2afdf7f"
DefaultAdminAppRoleID is OpenCloud's "Admin" app-role id, first measured on 7.3.0 (phase-0-findings.md, admin-role spike) and unchanged on 7.2.4 and 8.1.0 (Phase 9 spike). TestAdminAppRoleIDPinnedIntegration checks it on every CI leg.
const RequestIDHeader = "X-Request-Id"
RequestIDHeader carries the request id back to the client, so a user who reports a failure can quote the id the operator searches the log for.
Variables ¶
ErrKeySetUnavailable reports that signing keys could not be obtained. It is distinct from an invalid token: the caller answers 503, not 401.
var ErrNotConfigured = errors.New("api: server dependency not configured")
ErrNotConfigured is returned by constructors when a required dependency is missing. Exported for callers that wire the server.
Functions ¶
func IsAdminFrom ¶
IsAdminFrom reports whether the request context is flagged as an admin caller.
func WithResponseHeaders ¶
WithResponseHeaders sets responseHeaders before next runs, so they are on every answer next or anything wrapped in it writes, a recovered panic's included. Exported for the wiring in front of the API (a base-path mount), whose own 404 is a response of this service too.
Types ¶
type AdminResolver ¶
AdminResolver decides whether an authenticated caller is an OpenCloud admin. Implementations must never grant data powers — the flag gates target/grant management only (decisions.md #15).
type AdminResolverFunc ¶
AdminResolverFunc adapts a function to AdminResolver.
type AllowlistAdminResolver ¶
type AllowlistAdminResolver struct {
// contains filtered or unexported fields
}
AllowlistAdminResolver treats a fixed set of OpenCloud user ids as admins. This is the documented fallback for instances where graph detection is undesirable. The ids are OpenCloud's (as shown in its user management), not OIDC subjects, for the reason in users.go.
func NewAllowlistAdminResolver ¶
func NewAllowlistAdminResolver(userIDs []string) *AllowlistAdminResolver
NewAllowlistAdminResolver builds an allow-list resolver from user ids.
type Env ¶
type Env struct {
OIDC OIDCEnv `section:"Authentication"`
OpenCloud OpenCloudEnv `section:"OpenCloud"`
}
Env is the API's identity configuration, as the environment declares it: whose tokens it accepts, and where it asks who a caller is (internal/config).
type GraphAdminResolver ¶
type GraphAdminResolver struct {
// AdminAppRoleID is the graph appRoleId that denotes an admin.
AdminAppRoleID string
// contains filtered or unexported fields
}
GraphAdminResolver resolves admin status via the OpenCloud graph API using the caller's own bearer token. The default admin app-role id matches OpenCloud 7.3.0's static role bundle (phase-0-findings.md) and is overridable per deployment.
func NewGraphAdminResolver ¶
func NewGraphAdminResolver(baseURL, adminAppRoleID string, client *http.Client) *GraphAdminResolver
NewGraphAdminResolver constructs a graph resolver. If adminAppRoleID is empty the pinned default is used.
type GraphGroupResolver ¶
type GraphGroupResolver struct {
// contains filtered or unexported fields
}
GraphGroupResolver resolves group membership via the OpenCloud graph API (GET /graph/v1.0/me?$expand=memberOf) using the caller's own bearer token. OpenCloud 7.3.0 exposes no groups claim on the access token and has no /me/memberOf route, so the expanded /me document is the supported source.
func NewGraphGroupResolver ¶
func NewGraphGroupResolver(baseURL string, client *http.Client) *GraphGroupResolver
NewGraphGroupResolver builds a graph-backed group resolver.
type GraphUserResolver ¶
type GraphUserResolver struct {
// contains filtered or unexported fields
}
GraphUserResolver resolves the caller's OpenCloud user id from graph /me, once per token. The answer is cached until the token expires: a token names one user for its whole life, and OpenCloud's short-lived access tokens keep the cost at one graph call per user every few minutes.
func NewGraphUserResolver ¶
func NewGraphUserResolver(baseURL string, client *http.Client) *GraphUserResolver
NewGraphUserResolver builds a graph-backed user resolver.
type GroupResolver ¶
GroupResolver resolves the group ids an authenticated caller belongs to. Implementations must derive them from the identity provider or OpenCloud — never from client input.
type GroupResolverFunc ¶
GroupResolverFunc adapts a function to GroupResolver.
type Identity ¶
type Identity struct {
// Subject is the OIDC `sub` claim. It identifies the caller to the IdP and
// is NOT the OpenCloud user id; nothing here authorizes on it (users.go).
Subject string
// UserID is the caller's OpenCloud user id, as graph /me reports it. Every
// authorization decision that names a user uses this. Authenticate fills
// it in; a validator may set it itself when it already knows it.
UserID string
// Expiry is the token's `exp`, used to bound caches keyed by the token.
Expiry time.Time
// Username is the preferred human-readable name, if present.
Username string
// Token is the raw bearer token, retained so downstream calls (CS3 read,
// graph admin check) can forward the caller's credential. Never logged.
Token string
}
Identity is the authenticated caller extracted from a validated token. It is placed in the request context and never carries key material.
type KeySet ¶
type KeySet interface {
// Key returns the candidate verification keys for the given key id.
Key(ctx context.Context, keyID string) ([]jose.JSONWebKey, error)
}
KeySet resolves signing keys by key id. Implementations must be safe for concurrent use.
func NewJWKSKeySet ¶
NewJWKSKeySet builds a JWKS-backed KeySet from an explicit JWKS URI, skipping discovery. Useful for tests and deployments that pin the JWKS endpoint.
func NewLazyKeySet ¶
func NewLazyKeySet(issuer string, client *http.Client, cacheTTL time.Duration, logger *slog.Logger) KeySet
NewLazyKeySet returns a KeySet that performs OIDC discovery on first use. It never fails at construction, so an unavailable identity provider delays authentication instead of preventing startup.
type OIDCConfig ¶
type OIDCConfig struct {
// Issuer is the expected `iss` claim, e.g. https://cloud.example.org.
Issuer string
// Audience, when non-empty, is required in the token `aud` claim.
Audience string
// KeySet supplies verification keys (usually a JWKS-backed cache).
KeySet KeySet
// Leeway tolerates small clock skew on exp/nbf/iat. Defaults to 1 minute.
Leeway time.Duration
// Now overrides the clock for tests; defaults to time.Now.
Now func() time.Time
}
OIDCConfig configures the OpenCloud/Konnect token validator.
type OIDCEnv ¶
type OIDCEnv struct {
Issuer string `` /* 154-byte string literal not displayed */
Audience string `` /* 144-byte string literal not displayed */
}
OIDCEnv validates the bearer tokens the web extension sends.
type OpenCloudEnv ¶
type OpenCloudEnv struct {
BaseURL string `` /* 160-byte string literal not displayed */
AdminAppRoleID string `env:"OC_ADMIN_APP_ROLE_ID" doc:"Id of OpenCloud's admin app role. The default is OpenCloud's built-in Admin role."`
AdminAllowlist []string `` /* 179-byte string literal not displayed */
}
OpenCloudEnv is the Graph API side: who a caller is, their groups, their role.
type Option ¶
type Option func(*Server)
Option configures a Server.
func WithAdminResolver ¶
func WithAdminResolver(r AdminResolver) Option
WithAdminResolver sets the admin-status resolver used by ResolveAdmin.
func WithAuthorizer ¶
func WithAuthorizer(a targets.Authorizer) Option
WithAuthorizer sets the target authorizer backing GET /targets.
func WithBackupRunner ¶
func WithBackupRunner(r backupRunner) Option
WithBackupRunner sets the worker that executes backup runs. It is satisfied by *backup.Runner.
func WithBuildInfo ¶
WithBuildInfo replaces the running build's info, for tests.
func WithCredSealer ¶
func WithCredSealer(c targets.CredSealer) Option
WithCredSealer sets the Target-Wrap sealer used when an admin enters target credentials. The admin API only ever seals with it; opening a credential blob happens in worker memory at run time and nowhere else (decisions.md #14).
func WithGroupResolver ¶
func WithGroupResolver(r GroupResolver) Option
WithGroupResolver sets the resolver that answers which groups a caller belongs to, so group grants on a Space are honoured. Without it, a Space that carries a group grant refuses callers who need it (fail closed).
func WithJobStore ¶
WithJobStore sets the job store backing the run-history endpoint.
func WithKeyStore ¶
WithKeyStore sets the wrapped-key store backing the backup key endpoints.
func WithLogger ¶
WithLogger sets the logger request lines and the causes of 5xx answers go to. Without it they are discarded.
func WithNotificationStore ¶
func WithNotificationStore(n notificationReader) Option
WithNotificationStore sets the store backing a Space's notification feed. It is satisfied by *notify.StateStore.
func WithOpenCloudVersion ¶
func WithOpenCloudVersion(v openCloudVersion) Option
WithOpenCloudVersion sets the source of the OpenCloud version the service runs against. Without it the version route reports it as unknown.
func WithReadiness ¶
WithReadiness sets the readiness probe for GET /readyz.
func WithRestoreRunner ¶
func WithRestoreRunner(r restoreRunner) Option
WithRestoreRunner sets the worker that lists snapshots and executes Path B restores. It is satisfied by *restore.Runner.
func WithSRWWrapper ¶
func WithSRWWrapper(w srwWrapper) Option
WithSRWWrapper sets the Server Runtime Wrap holder used at key setup. It is satisfied by *keys.SRWWrapper.
func WithScheduleAdvisor ¶
func WithScheduleAdvisor(a scheduleAdvisor) Option
WithScheduleAdvisor sets the source of "next run" times. It is satisfied by *scheduler.Scheduler.
func WithSpaceConfigStore ¶
WithSpaceConfigStore sets the per-Space backup configuration store.
func WithSpaceReader ¶
func WithSpaceReader(r cs3.SpaceReader) Option
WithSpaceReader sets the CS3 space reader backing GET /spaces.
func WithStaleRule ¶
WithStaleRule sets the staleness rule the status endpoint reports. Pass the same rule the notify.Monitor is built with, or the card and the notification can disagree; the zero rule matches a monitor built with zero options.
func WithTargetChecker ¶
WithTargetChecker sets the read-only reachability checker backing the admin connection check. It is satisfied by objstore.S3Checker.
func WithTargetStore ¶
WithTargetStore sets the target/grant store backing the admin API. It is satisfied by *targets.StateStore, the same value WithAuthorizer takes.
func WithTokenValidator ¶
func WithTokenValidator(v TokenValidator) Option
WithTokenValidator sets the OIDC token validator used by Authenticate.
func WithUserResolver ¶
func WithUserResolver(r UserResolver) Option
WithUserResolver sets how an authenticated caller's OpenCloud user id is found (users.go). Without it, only a validator that supplies the id itself authenticates anyone.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server holds handler dependencies. Dependencies are injected via constructor options (testability rule); the zero Server is not usable — use NewServer.
func (*Server) Authenticate ¶
Authenticate is middleware that requires a valid bearer token. It extracts the Identity into the request context or responds 401 — or 503, when the token cannot be checked at all right now.
func (*Server) Handler ¶
Handler exposes the router, behind the common response headers, request ids, panic recovery and the request log, for mounting or testing.
func (*Server) RequireAdmin ¶
RequireAdmin is middleware that 403s non-admin callers. It must run after ResolveAdmin (and Authenticate).
func (*Server) ResolveAdmin ¶
ResolveAdmin is middleware that resolves the caller's admin status and stores it in the request context. It must run after Authenticate. A resolver error fails closed (isAdmin=false) rather than leaking internal details; the flag is UX/authorization metadata, and admin routes are additionally gated by RequireAdmin.
type TokenValidator ¶
TokenValidator validates bearer tokens and extracts an Identity. It is the boundary the HTTP middleware depends on, so tests can substitute a fake.
func NewOIDCValidator ¶
func NewOIDCValidator(cfg OIDCConfig) (TokenValidator, error)
NewOIDCValidator constructs a validator. It does not perform network I/O; discovery/JWKS fetching is the KeySet's responsibility.
Audience is required. An OpenCloud deployment issues tokens for several clients from one issuer, so accepting "any token this issuer signed" accepts tokens minted for something else entirely — including ID tokens.
type UserResolver ¶
UserResolver maps an authenticated caller to their OpenCloud user id. Implementations must derive it from OpenCloud with the caller's own credential — never from client input.
type UserResolverFunc ¶
UserResolverFunc adapts a function to UserResolver.