credential

package
v0.60.0 Latest Latest
Warning

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

Go to latest
Published: Jul 19, 2026 License: AGPL-3.0 Imports: 16 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 authorization lives here. Per-kind differences are thin sub-resolvers (pat.go, scoped.go, internal.go), not logic sprinkled across handlers. Adding a scope touches scopes.go; changing the token format touches mint.go; changing authz touches enforce.go; adding a token kind adds one sub-resolver and leaves the front door intact.

Index

Constants

View Source
const (
	// PATPrefix identifies a personal access token presented to the API.
	PATPrefix = "stella_pat_"
	// 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.

View Source
const ScopeAgentWrite = "agent:" + ActionWrite

ScopeAgentWrite guards posting messages / triggering an agent run. The webhook ingress (POST /webhooks/{id}) is auth-exempt and bypasses Enforce, so it checks this constant directly; defining it here ties that surface to the same catalog mapping scopeForMethod applies to the equivalent /api routes (see TestScopeAgentWriteMatchesRouteMapping).

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 ErrPATScopeInvalid = errors.New("credential: invalid pat scope")

ErrPATScopeInvalid marks a caller-fixable PAT-creation error (empty or non-grantable scope). CreatePAT wraps such errors with it so the HTTP layer can answer 400 for these and 500 for genuine store/mint failures, instead of mapping every failure to 400 and echoing internal error text to the client.

Functions

func Enforce

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

Enforce covers bearer kind + 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. It runs three explicit layers:

  1. kind policy -- pat/oauth are the only accepted bearer kinds.
  2. method+path -> required scope -- RequiredScope classifies every /api route to a scope; an unregistered route is deny (fail-closed).
  3. reachability by kind -- PAT/OAuth use the catalog's PAT-exposable surface (patReachable).

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

func ExposableScopes

func ExposableScopes() []string

ExposableScopes returns the concrete scope strings a PAT may be granted (resource:read and resource:write for every exposable resource, plus the resource:* wildcard).

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-but-token-denied route (admin/auth/self-management), which Enforce rejects for every kind.
  • 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).

func ValidatePATScopes

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

ValidatePATScopes rejects unknown, malformed, or non-exposable scopes. It returns the first offending scope. An empty scope set is rejected: a PAT with no scope can do nothing and is almost always a mistake.

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"
	// 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
	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)
	RevokePAT(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) 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) TouchPATLastUsed

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

type Principal

type Principal struct {
	Kind   Kind
	UserID string
	// Scopes is the granted API-permission scope set (resource:action / resource:*).
	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 the PAT creation UI / OAuth consent.
	Description string
	// ExposableToPAT controls whether a PAT may be granted this resource.
	// vault/oauth are sandbox-internal and default-denied to external tokens.
	ExposableToPAT bool
	// ExposableToOAuth controls whether a third-party OAuth2 client may be
	// granted this resource via consent. Same default-deny policy as PATs:
	// 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 UI/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, scopes []string, expiresAt *time.Time) (plaintext string, rec PATRecord, err error)

CreatePAT validates the requested scopes against the exposability policy, mints an opaque token, and persists it. The plaintext is returned exactly once and is never recoverable afterwards.

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

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

RevokeUserPATs cascade-revokes every active PAT a user holds. Call it when the account is deactivated so tokens do not silently reactivate with the account. It returns the number of tokens revoked.

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