api

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 21, 2026 License: Apache-2.0 Imports: 35 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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

View Source
var ErrKeySetUnavailable = errors.New("oidc: signing keys are unavailable")

ErrKeySetUnavailable reports that signing keys could not be obtained. It is distinct from an invalid token: the caller answers 503, not 401.

View Source
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

func IsAdminFrom(ctx context.Context) bool

IsAdminFrom reports whether the request context is flagged as an admin caller.

func WithResponseHeaders

func WithResponseHeaders(next http.Handler) http.Handler

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

type AdminResolver interface {
	IsAdmin(ctx context.Context, id Identity) (bool, error)
}

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

type AdminResolverFunc func(ctx context.Context, id Identity) (bool, error)

AdminResolverFunc adapts a function to AdminResolver.

func (AdminResolverFunc) IsAdmin

func (f AdminResolverFunc) IsAdmin(ctx context.Context, id Identity) (bool, error)

IsAdmin implements 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.

func (*AllowlistAdminResolver) IsAdmin

IsAdmin reports whether the caller's user id is in the allow-list.

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

func DefaultEnv

func DefaultEnv() Env

DefaultEnv is the configuration of an unset environment.

func (*Env) Validate

func (e *Env) Validate() error

Validate refuses an issuer the API could not use safely.

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.

func (*GraphAdminResolver) IsAdmin

func (g *GraphAdminResolver) IsAdmin(ctx context.Context, id Identity) (bool, error)

IsAdmin calls the graph API as the caller and maps their app-role assignments to admin status. A non-admin (or a user without the admin role) yields false; transport/graph errors are returned so the caller can decide (fail-closed).

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.

func (*GraphGroupResolver) Groups

func (g *GraphGroupResolver) Groups(ctx context.Context, id Identity) ([]string, error)

Groups returns the caller's group ids.

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.

func (*GraphUserResolver) UserID

func (g *GraphUserResolver) UserID(ctx context.Context, id Identity) (string, error)

UserID returns the caller's OpenCloud user id. The cache key is a hash of the token, so the cache holds no bearer credential.

type GroupResolver

type GroupResolver interface {
	Groups(ctx context.Context, id Identity) ([]string, error)
}

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

type GroupResolverFunc func(ctx context.Context, id Identity) ([]string, error)

GroupResolverFunc adapts a function to GroupResolver.

func (GroupResolverFunc) Groups

func (f GroupResolverFunc) Groups(ctx context.Context, id Identity) ([]string, error)

Groups implements 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.

func IdentityFrom

func IdentityFrom(ctx context.Context) (Identity, bool)

IdentityFrom returns the authenticated identity from ctx, if any.

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

func NewJWKSKeySet(jwksURI string, client *http.Client, cacheTTL time.Duration) KeySet

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

func WithBuildInfo(info buildinfo.Info) Option

WithBuildInfo replaces the running build's info, for tests.

func WithClock

func WithClock(now func() time.Time) Option

WithClock sets the time source used to evaluate grant expiry.

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

func WithJobStore(st jobs.Store) Option

WithJobStore sets the job store backing the run-history endpoint.

func WithKeyStore

func WithKeyStore(st keys.Store) Option

WithKeyStore sets the wrapped-key store backing the backup key endpoints.

func WithLogger

func WithLogger(l *slog.Logger) Option

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

func WithReadiness(fn func(context.Context) error) Option

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

func WithSpaceConfigStore(st spacecfg.Store) Option

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

func WithStaleRule(r notify.StaleRule) Option

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

func WithTargetChecker(c objstore.Checker) Option

WithTargetChecker sets the read-only reachability checker backing the admin connection check. It is satisfied by objstore.S3Checker.

func WithTargetStore

func WithTargetStore(st targets.Store) Option

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 NewServer

func NewServer(opts ...Option) *Server

NewServer constructs the HTTP server and registers routes.

func (*Server) Authenticate

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

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

func (s *Server) Handler() http.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

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

RequireAdmin is middleware that 403s non-admin callers. It must run after ResolveAdmin (and Authenticate).

func (*Server) ResolveAdmin

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

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

type TokenValidator interface {
	Validate(ctx context.Context, rawToken string) (Identity, error)
}

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

type UserResolver interface {
	UserID(ctx context.Context, id Identity) (string, error)
}

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

type UserResolverFunc func(ctx context.Context, id Identity) (string, error)

UserResolverFunc adapts a function to UserResolver.

func (UserResolverFunc) UserID

func (f UserResolverFunc) UserID(ctx context.Context, id Identity) (string, error)

UserID implements UserResolver.

Jump to

Keyboard shortcuts

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