credential

package
v0.68.2 Latest Latest
Warning

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

Go to latest
Published: Sep 7, 2026 License: AGPL-3.0 Imports: 17 Imported by: 0

Documentation

Overview

Package credential is the single front door for turning any bearer credential presented to the HTTP API into an authenticated Principal, and for enforcing what that principal may do.

Anti-scatter rule: all credential resolution and entry authorization lives here. Per-kind differences are thin sub-resolvers, not logic sprinkled across handlers. OAuth scopes live in scopes.go; token formats live in mint.go; entry policy lives in enforce.go.

Index

Constants

View Source
const (
	// PATPrefix identifies a personal access token presented to the API.
	PATPrefix = "stella_pat_"
	// ProvisioningPrefix identifies a restricted provisioning token presented to
	// the API. Its distinct wire namespace must match the stored token use.
	ProvisioningPrefix = "stella_prv_"
	// OAuthAccessPrefix identifies an OAuth2 access token. Reserved for #613.
	OAuthAccessPrefix = "stella_oat_"
	// OAuthRefreshPrefix identifies an OAuth2 refresh token. Valid only at the
	// token endpoint -- hard-rejected at the API boundary. Reserved for #613.
	OAuthRefreshPrefix = "stella_ort_"
)

Family prefixes. These are the DISPATCH namespace (which resolver handles a bearer), reserved up front so surfaces never collide. A public_id (below) is a separate concept: the indexed LOOKUP key inside a family.

View Source
const (
	ActionRead  = "read"
	ActionWrite = "write"
)

Scope actions.

Variables

View Source
var ErrForbidden = errors.New("permission denied")

ErrForbidden is returned by Enforce when a principal may not perform a request.

View Source
var ErrProvisioningTokenLimit = errors.New("credential: provisioning token limit reached")

ErrProvisioningTokenLimit prevents an admin session from accumulating more than the two credentials needed for a safe rotation overlap.

Functions

func Enforce

func Enforce(p *Principal, method, path string) error

Enforce covers bearer kind + route scope. Object-level ownership (this user owns this task/agent/session) is NOT done here -- it remains a per-handler responsibility for PAT/OAuth, exactly as for cookie sessions. PATs are API-only credentials for their owner's current authority and pass this gate; OAuth access tokens additionally run through route-to-scope enforcement.

OAuth enforcement runs three explicit layers:

  1. method+path -> required scope -- RequiredScope classifies every /api route to a scope; an unregistered route is deny (fail-closed).
  2. OAuth reachability -- the catalog's OAuth-exposable surface.
  3. granted scope match.

Cookie/OIDC sessions have no Principal and are never passed here.

func HashSecret

func HashSecret(secret string) string

HashSecret returns the SHA-256 hex of an opaque-token secret. Exported so internal/oidc hashes refresh-token secrets against the same format authority instead of re-deriving it.

func MatchScope

func MatchScope(granted []string, required string) bool

MatchScope reports whether a granted scope set satisfies a required resource:action. A granted resource:* covers any action on that resource.

func OAuthGrantableScopes

func OAuthGrantableScopes() []string

OAuthGrantableScopes returns the concrete scope strings an OAuth2 client may be granted (resource:read, resource:write, and resource:* for every OAuth-exposable resource).

func ParseOpaqueToken

func ParseOpaqueToken(prefix, raw string) (publicID, secret string, err error)

ParseOpaqueToken is the exported opaque-token splitter for internal/oidc's refresh tokens, which share the wire format but are resolved at /oauth/token rather than the API front door. Same contract as the internal resolver path.

func RequiredScope

func RequiredScope(method, path string) (scope string, registered bool)

RequiredScope maps an HTTP method + /api path to the scope needed to reach it. It returns:

  • scope: the required resource:action; "" for a known OAuth-token-denied route (admin/auth/self-management).
  • registered: whether the route was classified at all.

registered=false means a route exists that nobody classified -- Enforce treats it as deny (fail-closed), and TestEveryAPIRouteHasRegisteredScope asserts every generated /api route (down to its sub-resource) is registered, so the gap surfaces at build time, not in production. Classification is at sub-resource granularity: an unrecognized /api/agents/{id}/<new> returns registered=false rather than silently inheriting the broad agent scope.

func ScopesSubset

func ScopesSubset(sub, super []string) (string, bool)

ScopesSubset reports whether every scope in sub is satisfied by super, where a resource:* in super covers resource:read/resource:write in sub. This is the subset-chain primitive: issued scopes <= code scopes <= client scopes <= user permissions. It returns the first scope in sub not covered by super.

func ValidateOAuthScopes

func ValidateOAuthScopes(scopes []string) (string, bool)

ValidateOAuthScopes rejects unknown, malformed, or non-OAuth-exposable scopes, returning the first offending scope. It is the client-allowed-scope gate: a client may only be registered with scopes that pass here. An empty set is allowed (a client with no default scopes requests them per-authorization).

Types

type Config

type Config struct {
	PATs   PATStore
	OAuth  OAuthAccessStore
	Users  UserLookup
	Now    func() time.Time
	Logger *slog.Logger
}

Config wires the backends a Service needs. PATs/Users may be nil (PAT auth disabled); OAuth may be nil until OAuth bearer support is enabled.

type Identity

type Identity struct {
	UserID    string
	Username  string
	Email     string
	Name      string
	AvatarURL string
	Role      string
	IsAdmin   bool
	IsActive  bool
}

Identity is the user snapshot a backend returns for a resolved credential.

type Kind

type Kind string

Kind identifies which credential family a Principal was resolved from.

const (
	// KindPAT is a user-owned personal access token (stella_pat_).
	KindPAT Kind = "pat"
	// KindProvisioning is an admin-owned, API-only credential restricted to the
	// provisioned-user resource family (stella_prv_).
	KindProvisioning Kind = "provisioning"
	// KindOAuth is an OAuth2 access token (stella_oat_). Reserved for #613.
	KindOAuth Kind = "oauth"
)

type Minted

type Minted struct {
	Plaintext string // shown to the user exactly once
	PublicID  string // indexed lookup key
	TokenHash string // SHA-256 hex of the secret; what we store and compare
	Last4     string // last 4 chars of the plaintext, for display
}

Minted is the output of MintOpaque: the one-time plaintext plus the fields persisted for later O(1) lookup and verification.

func MintOpaque

func MintOpaque(kind Kind) (Minted, error)

MintOpaque mints a high-entropy opaque bearer token for a given kind. It is the kind-checked entry point for opaque PAT/OAuth-access tokens. It must not absorb client_secret password-hashing -- that is a distinct concern kept out of the generic mint. OAuth refresh tokens share the same wire format but rotate outside the API front door, so internal/oidc mints them via MintOpaqueWithPrefix rather than this kind-checked form.

func MintOpaqueWithPrefix

func MintOpaqueWithPrefix(prefix string) (Minted, error)

MintOpaqueWithPrefix mints an opaque token for an explicit prefix. It is the single definition of the opaque-token wire format -- prefix + public_id + "_" + secret + crc32, with the secret stored as a SHA-256 hash. MintOpaque is the PAT/OAuth-access entry point; internal/oidc uses this lower-level form for stella_ort_ refresh tokens so the format lives in exactly one place.

type OAuthAccessRecord

type OAuthAccessRecord struct {
	ID              string
	PublicID        string
	TokenHash       string
	Last4           string
	ClientID        string
	UserID          string
	Scopes          []string
	RefreshFamilyID string // the refresh family this token belongs to; always set
	ExpiresAt       time.Time
	LastUsedAt      *time.Time
	// FamilyRevokedAt is the revoked_at of the token's refresh family (nil while
	// live). Revocation is a single flag on the family, checked here at read time
	// so an access token can never outlive its revoked family.
	FamilyRevokedAt *time.Time
	CreatedAt       time.Time
}

OAuthAccessRecord is the storage-agnostic shape of an oauth_access_token row. It is the opaque stella_oat_ credential that resolves to a Principal{Kind:oauth} through the same front door as a PAT. Access tokens are NEVER JWTs.

type OAuthAccessStore

type OAuthAccessStore interface {
	CreateOAuthAccess(ctx context.Context, rec OAuthAccessRecord) (OAuthAccessRecord, error)
	GetOAuthAccessByPublicID(ctx context.Context, publicID string) (OAuthAccessRecord, error)
	TouchOAuthAccessLastUsed(ctx context.Context, id string) (int64, error)
}

OAuthAccessStore is the storage backend for the oauth_access_token table. Like PATStore it is the only OAuth storage the credential package touches directly: the client/code/refresh tables live in internal/oidc (the zitadel/oidc-shaped authorization-server storage). Keeping access-token resolution here is the anti-scatter guarantee -- every /api credential resolves through one front door.

type PATRecord

type PATRecord struct {
	ID         string
	PublicID   string
	UserID     string
	Name       string
	TokenHash  string
	Last4      string
	Scopes     []string
	TokenUse   TokenUse
	ExpiresAt  *time.Time
	LastUsedAt *time.Time
	RevokedAt  *time.Time
	CreatedAt  time.Time
}

PATRecord is the storage-agnostic shape of a personal_access_token row.

type PATStore

type PATStore interface {
	CreatePAT(ctx context.Context, rec PATRecord) (PATRecord, error)
	GetPATByPublicID(ctx context.Context, publicID string) (PATRecord, error)
	ListPATByUser(ctx context.Context, userID string) ([]PATRecord, error)
	ListProvisioningTokenByUser(ctx context.Context, userID string) ([]PATRecord, error)
	RevokePAT(ctx context.Context, id, userID string) (int64, error)
	RevokeProvisioningToken(ctx context.Context, id, userID string) (int64, error)
	RevokePATByUser(ctx context.Context, userID string) (int64, error)
	TouchPATLastUsed(ctx context.Context, id string) (int64, error)
}

PATStore is the storage backend for the personal_access_token table. It is the only storage the credential package touches directly; OAuth access tokens use OAuthAccessStore.

type PostgresStore

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

PostgresStore adapts the sqlc queries to PATStore + UserLookup. It is the only place the PAT table shape and the auth_user lookup are translated to the storage-agnostic credential records. The composition root constructs it once and hands it to NewService as both the PATs and Users dependency.

func NewPostgresStore

func NewPostgresStore(db *pgxpool.Pool) *PostgresStore

NewPostgresStore builds the PAT/user-lookup store over the shared pool.

func (*PostgresStore) CreatePAT

func (p *PostgresStore) CreatePAT(ctx context.Context, rec PATRecord) (PATRecord, error)

func (*PostgresStore) GetPATByPublicID

func (p *PostgresStore) GetPATByPublicID(ctx context.Context, publicID string) (PATRecord, error)

func (*PostgresStore) ListPATByUser

func (p *PostgresStore) ListPATByUser(ctx context.Context, userID string) ([]PATRecord, error)

func (*PostgresStore) ListProvisioningTokenByUser added in v0.63.0

func (p *PostgresStore) ListProvisioningTokenByUser(ctx context.Context, userID string) ([]PATRecord, error)

func (*PostgresStore) LookupUser

func (p *PostgresStore) LookupUser(ctx context.Context, userID string) (Identity, error)

func (*PostgresStore) RevokePAT

func (p *PostgresStore) RevokePAT(ctx context.Context, id, userID string) (int64, error)

func (*PostgresStore) RevokePATByUser

func (p *PostgresStore) RevokePATByUser(ctx context.Context, userID string) (int64, error)

func (*PostgresStore) RevokeProvisioningToken added in v0.63.0

func (p *PostgresStore) RevokeProvisioningToken(ctx context.Context, id, userID string) (int64, error)

func (*PostgresStore) TouchPATLastUsed

func (p *PostgresStore) TouchPATLastUsed(ctx context.Context, id string) (int64, error)

type Principal

type Principal struct {
	Kind         Kind
	UserID       string
	CredentialID string // provisioning row ID for future request attribution
	// Scopes is populated only for delegated OAuth access tokens. PAT authority
	// comes from the owner's current account and ignores legacy stored scopes.
	Scopes []string

	// Identity snapshot.
	Username  string
	Email     string
	Name      string
	AvatarURL string
	Role      string
	IsAdmin   bool
}

Principal is the single output type of Resolve: the authenticated identity plus the authorization inputs Enforce needs. Identity fields are a snapshot taken at resolve time so callers can build their request context without a second user lookup.

func (Principal) Authority

func (p Principal) Authority() (authz.Authority, error)

Migration adapter: credential.Principal -> authz.Authority.

This adapter lives in the credential package (the trusted producer of a resolved bearer identity) rather than in authz, so the pure authz core does not import the credential resolver and the dependency direction points at authz. It is single-direction: there is no authz.Authority -> Principal reverse path.

A resolved bearer credential is always a user acting through the HTTP API, so it maps to a UserActor, admin flag mapped straight through. The credential's API-permission scopes are deliberately NOT part of the Authority: the route-to-scope entry gate is enforced solely at credential.Enforce, so it stays an entry concern and never becomes an authorization capability the domains reason about.

type Scope

type Scope struct {
	// Resource is the left side of resource:action (e.g. "goals").
	Resource string
	// Description is human-facing copy for OAuth consent.
	Description string
	// ExposableToOAuth controls whether a third-party OAuth2 client may be
	// granted this resource via consent. vault:* / oauth:* are never grantable
	// to an OAuth client.
	ExposableToOAuth bool
}

Scope is one entry in the authoritative API-permission catalog. These are the resource:action permissions checked at the HTTP boundary. They are a DIFFERENT axis from skill/vault storage scopes (system / user / agent ownership) -- do not conflate scopes.Resource with a skill's storage scope.

func Catalog

func Catalog() []Scope

Catalog returns the scope catalog (copy) for OAuth consent surfaces.

type Service

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

Service is the single front door: Resolve (identity) + Enforce (authz, a free function) + PAT lifecycle. Construct one with NewService and wire it into the HTTP middleware.

func NewService

func NewService(cfg Config) *Service

NewService builds the credential front door.

func (*Service) CreatePAT

func (s *Service) CreatePAT(ctx context.Context, userID, name string, expiresAt *time.Time) (plaintext string, rec PATRecord, err error)

CreatePAT mints an opaque token for its owner's current authority and persists it. The plaintext is returned exactly once and is never recoverable afterwards.

func (*Service) CreateProvisioningToken added in v0.63.0

func (s *Service) CreateProvisioningToken(ctx context.Context, userID, name string, expiresAt time.Time) (plaintext string, rec PATRecord, err error)

CreateProvisioningToken mints an admin-owned bearer that can reach only the provisioned-user API family. The caller gets its plaintext exactly once.

func (*Service) GetPAT

func (s *Service) GetPAT(ctx context.Context, id, userID string) (PATRecord, bool, error)

GetPAT returns one of the user's own PATs by id. The scan is over the caller's tokens only, so ownership is enforced before existence is revealed: a token owned by another user reports found=false, identical to a missing one. Linear over the user's list (bounded, a handful per user); add a scoped single-row query if that ever stops holding.

func (*Service) IssueOAuthAccess

func (s *Service) IssueOAuthAccess(ctx context.Context, userID, clientID string, scopes []string, refreshFamilyID string, ttl time.Duration) (plaintext string, err error)

IssueOAuthAccess mints an opaque stella_oat_ access token and persists it under a refresh family. It is the ONLY way the authorization-server token endpoint obtains an access token: internal/oidc calls this instead of ever emitting a JWT, so the opaque-token guardrail holds by construction. The plaintext is returned once; the persisted record is not, since no caller uses it.

func (*Service) ListPAT

func (s *Service) ListPAT(ctx context.Context, userID string) ([]PATRecord, error)

ListPAT returns a user's PATs (never the secret; hashes stay in storage).

func (*Service) ListProvisioningTokens added in v0.63.0

func (s *Service) ListProvisioningTokens(ctx context.Context, userID string) ([]PATRecord, error)

ListProvisioningTokens returns only provisioning tokens owned by an admin.

func (*Service) Resolve

func (s *Service) Resolve(ctx context.Context, header string) (*Principal, error)

Resolve turns a raw Authorization header value into a Principal, dispatched by family prefix. Its three-way return is the security-critical contract:

  • (nil, nil): no Bearer credential present. The caller SHOULD fall back to cookie/session auth.
  • (nil, err): a Bearer credential is present but invalid, unknown, or reserved. The caller MUST deny and MUST NOT fall back to any full-access path.
  • (principal, nil): success.

func (*Service) RevokePAT

func (s *Service) RevokePAT(ctx context.Context, id, userID string) (bool, error)

RevokePAT revokes one of the user's own PATs. It reports whether a row was revoked (false = not found or already revoked).

func (*Service) RevokeProvisioningToken added in v0.63.0

func (s *Service) RevokeProvisioningToken(ctx context.Context, id, userID string) (bool, error)

RevokeProvisioningToken revokes a provisioning token owned by its issuing admin. A personal token with the same id is deliberately indistinguishable from a missing provisioning token.

func (*Service) RevokeUserPATs

func (s *Service) RevokeUserPATs(ctx context.Context, userID string) (int64, error)

RevokeUserPATs cascade-revokes every active personal_access_token use a user holds. Call it when the account is deactivated so neither personal nor provisioning tokens silently reactivate with the account.

type TokenUse added in v0.63.0

type TokenUse string

TokenUse identifies the intended authority model for a row in personal_access_token. Keep this extensible enum validated here, at the one persistence boundary, rather than freezing it in a database CHECK constraint.

const (
	TokenUsePersonal     TokenUse = "personal"
	TokenUseProvisioning TokenUse = "provisioning"
)

func (TokenUse) Valid added in v0.63.0

func (u TokenUse) Valid() bool

Valid reports whether token use is recognized by this credential version.

type UserLookup

type UserLookup interface {
	LookupUser(ctx context.Context, userID string) (Identity, error)
}

UserLookup resolves a user id to an identity snapshot for a resolved PAT.

Jump to

Keyboard shortcuts

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