Documentation
¶
Overview ¶
Package registry reads (and minimally mutates) the actor / actor_keys / tenants / actor_memberships tables. The auth middleware uses it for every signed request; admin endpoints use it for enrolment, listing, revocation, and membership management.
Index ¶
- Constants
- Variables
- func HashToken(token string) string
- type Actor
- type Bootstrap
- type ConsumeResult
- type Dialect
- type Invitation
- type Key
- type Membership
- type Registry
- func (r *Registry) ConsumeBootstrap(ctx context.Context, plaintext, consumerIP string) (*Bootstrap, error)
- func (r *Registry) ConsumeInvitation(ctx context.Context, tokenHash, newActorID, newKeyID string, ...) (*ConsumeResult, error)
- func (r *Registry) CreateActor(ctx context.Context, a Actor) error
- func (r *Registry) CreateBootstrap(ctx context.Context, actorID, tenantID string, caps []string, label string, ...) (plaintext string, expiresAt time.Time, err error)
- func (r *Registry) CreateInvitation(ctx context.Context, inv Invitation) error
- func (r *Registry) CreateKey(ctx context.Context, k Key) error
- func (r *Registry) CreateMembership(ctx context.Context, m Membership) (*Membership, error)
- func (r *Registry) CreateSession(ctx context.Context, b *Bootstrap, ua, ip string, ttl time.Duration) (*Session, error)
- func (r *Registry) GetSession(ctx context.Context, sessionID string) (*Session, error)
- func (r *Registry) HasAnyActiveActor(ctx context.Context) (bool, error)
- func (r *Registry) ListActors(ctx context.Context) ([]Actor, error)
- func (r *Registry) ListInvitations(ctx context.Context) ([]Invitation, error)
- func (r *Registry) ListMembershipsForActor(ctx context.Context, actorID string) ([]Membership, error)
- func (r *Registry) ListMembershipsForTenant(ctx context.Context, tenantID string) ([]Membership, error)
- func (r *Registry) ListSessions(ctx context.Context, tenantID string) ([]Session, error)
- func (r *Registry) LoadMembership(ctx context.Context, actorID, tenantID string) (*Membership, error)
- func (r *Registry) LookupActor(ctx context.Context, actorID string) (*Actor, error)
- func (r *Registry) LookupInvitationByTokenHash(ctx context.Context, tokenHash string) (*Invitation, error)
- func (r *Registry) LookupKey(ctx context.Context, keyID string) (*Key, error)
- func (r *Registry) LookupKeyByPublicKey(ctx context.Context, pub ed25519.PublicKey) (*Key, error)
- func (r *Registry) RevokeActor(ctx context.Context, actorID string) error
- func (r *Registry) RevokeActorSessions(ctx context.Context, actorID, by string) error
- func (r *Registry) RevokeInvitation(ctx context.Context, invitationID string) error
- func (r *Registry) RevokeKey(ctx context.Context, keyID string) error
- func (r *Registry) RevokeMembership(ctx context.Context, actorID, tenantID string) error
- func (r *Registry) RevokeSession(ctx context.Context, sessionID, by string) error
- func (r *Registry) SetActorSuperAdmin(ctx context.Context, actorID string, super bool) error
- func (r *Registry) TouchSession(ctx context.Context, sessionID string, now time.Time) error
- type Session
- type TenantLookup
Constants ¶
const DefaultTenantID = "tnt_default"
DefaultTenantID mirrors the seeded tenant_id from db/schema/sqlite/runtime/0002_tenants.sql. Kept as a local constant (rather than importing chassis/tenants) so the auth registry stays independent of the runtime DB's package layout — invitations and pre-tenant backfills default to this id, and the value must match what the runtime seed inserts.
Variables ¶
var ErrBootstrapInvalid = errors.New("bootstrap token invalid or expired")
ErrBootstrapInvalid covers all the "this token can't be redeemed" cases — never minted, expired, already consumed. We collapse them into one error to avoid leaking which subtype the caller hit, which would let an attacker probe token existence.
var ErrKeyAlreadyEnrolled = errors.New("key already enrolled")
ErrKeyAlreadyEnrolled is returned by CreateKey when the public_key is already enrolled by a non-revoked actor_keys row (enforced via the partial UNIQUE index in migration 0009). Handlers surface this as a 409 with the existing actor_id so the caller knows to use invite/accept or revoke the old key first.
var ErrNotFound = errors.New("not found")
ErrNotFound is returned by lookups when no matching row exists. The caller (middleware) maps this to unknown_key / actor_revoked / etc.
Functions ¶
Types ¶
type Actor ¶
type Actor struct {
ActorID string
Label string
Kind string
Subject string
Tenant string
Stack string
SuperAdmin bool
CreatedAt time.Time
RevokedAt *time.Time
Meta string
}
Actor mirrors the actors table row.
Tenant and Stack are vestiges of v1's chassis-wide model and are no longer the source of truth for authz scoping — auth_memberships is. They linger as nullable columns until phase 4's table rebuild and stay readable here for back-compat with existing meta and tooling. SuperAdmin is the chassis-wide override: actors with super_admin=1 pass every RequireCapability regardless of membership.
type Bootstrap ¶
type Bootstrap struct {
TokenHash string
ActorID string
TenantID string
Capabilities []string
Label string
CreatedAt time.Time
ExpiresAt time.Time
ConsumedAt *time.Time
ConsumedIP string
}
Bootstrap is one row of browser_bootstrap.
type ConsumeResult ¶
type ConsumeResult struct {
Invitation Invitation
ActorID string
KeyID string
TenantID string
Reused bool
}
ConsumeResult is the outcome of a successful ConsumeInvitation. Reused is true when the consumer's public key was already enrolled — in that case ActorID and KeyID point at the EXISTING principal, so a single ssh-agent key can collect memberships across many tenants without spawning duplicate actor rows.
type Dialect ¶
type Dialect interface {
// Rebind rewrites `?` placeholders for the target engine. SQLite
// returns the query unchanged; Postgres returns `$1, $2, …`.
Rebind(query string) string
// IsUniqueViolation reports whether err is the actor_keys public-key
// uniqueness conflict (the only UNIQUE the registry distinguishes —
// it surfaces as ErrKeyAlreadyEnrolled).
IsUniqueViolation(err error) bool
// BeginImmediate starts the transaction used by the atomic consume
// paths (invitation / bootstrap redemption) with an upfront write
// lock appropriate to the engine.
BeginImmediate(ctx context.Context, db *sql.DB) (*sql.Tx, error)
}
Dialect isolates the handful of SQL behaviours that differ between the in-tree default (SQLite) and a shared Postgres auth store used by an HA control plane. The registry SQL is otherwise identical: timestamps are RFC3339 TEXT, JSON is TEXT, booleans are INTEGER 0/1, and upserts use the `INSERT … ON CONFLICT(...) DO UPDATE SET … excluded.…` form that both engines accept — so nothing here touches Go scan/marshal.
Only three things vary:
- Placeholders: SQLite binds `?`, Postgres binds `$1, $2, …`.
- Unique-violation detection: SQLite carries it in the error string, Postgres in SQLSTATE 23505. We never import a driver here — the Postgres case duck-types `interface{ SQLState() string }` so core keeps SQLite as its only compiled driver.
- The upfront write lock: SQLite uses `BEGIN IMMEDIATE` to fail fast instead of deadlocking mid-transaction; Postgres uses a SERIALIZABLE transaction. Correctness of the single-winner consume paths comes from the conditional `UPDATE … WHERE consumed_at IS NULL` (rows-affected) guard, which is dialect-neutral — this is only the lock strategy.
var Postgres Dialect = postgresDialect{}
Postgres is selected when the auth DSN is a postgres:// URL. The driver itself is registered out of tree (overlay blank-import), never compiled into core.
var SQLite Dialect = sqliteDialect{}
SQLite is the default dialect — identity placeholders, error-string unique detection, and the historical `BEGIN IMMEDIATE` behaviour. Behaviour is byte-for-byte what the registry did before the seam.
func DialectForDSN ¶
DialectForDSN picks the dialect from an auth DSN. Anything that isn't a recognised Postgres URL stays on SQLite (the safe default).
type Invitation ¶
type Invitation struct {
InvitationID string
TokenHash string // hex sha-256 of the raw token
Label string
Kind string
TenantID string
Capabilities []string
CreatedBy string
CreatedAt time.Time
ExpiresAt time.Time
ConsumedAt *time.Time
ConsumedBy string
RevokedAt *time.Time
}
Invitation mirrors the actor_invitations table. Capabilities is the decoded JSON array; v1 always materialises ["admin:all"] (no NULL). TenantID is the slug the invitation scopes its membership grant to — added in migration 0008, backfilled to DefaultTenantID for older rows. Empty in only one situation: a pre-0008 invitation that somehow escaped the backfill, which the handler treats as default.
type Key ¶
type Key struct {
KeyID string
ActorID string
PublicKey ed25519.PublicKey
Algorithm string
CreatedAt time.Time
RevokedAt *time.Time
Meta string
}
Key mirrors actor_keys. PublicKey is the raw 32-byte Ed25519 public key.
type Membership ¶
type Membership struct {
ActorID string
TenantID string
TenantSlug string
Capabilities []string
CreatedAt time.Time
RevokedAt *time.Time
}
Membership mirrors actor_memberships. Capabilities is the decoded JSON column — never NULL, always at least one entry. TenantSlug is populated by the optional TenantLookup passed to New; when no resolver is configured (or it returns ErrNotFound) the slug is left empty.
type Registry ¶
type Registry struct {
DB *sql.DB
Tenants TenantLookup
// Dialect adapts the few SQL behaviours that differ between the
// in-tree SQLite default and a shared Postgres auth store (HA
// control plane). nil ⇒ SQLite (see dia()).
Dialect Dialect
}
Registry is the thin façade over the auth tables. It takes a *sql.DB directly so it can run inside transactions started by the admin handlers (e.g. when enrollment writes an actor + a key + capabilities atomically).
Tenants is the cross-DB slug resolver — tenants live in runtime.db (chassis/tenants package), and membership queries need slugs for the common authz / display paths. May be nil for tests or contexts that don't care about slug enrichment; membership methods then leave TenantSlug empty.
func New ¶
func New(db *sql.DB, tenants TenantLookup) *Registry
New builds a Registry on the SQLite dialect (the in-tree default). tenants may be nil — see TenantLookup doc.
func NewWithDialect ¶
func NewWithDialect(db *sql.DB, tenants TenantLookup, d Dialect) *Registry
NewWithDialect builds a Registry on an explicit dialect — used when the auth DSN selects Postgres for an HA control plane. A nil dialect falls back to SQLite.
func (*Registry) ConsumeBootstrap ¶
func (r *Registry) ConsumeBootstrap(ctx context.Context, plaintext, consumerIP string) (*Bootstrap, error)
ConsumeBootstrap is the one-shot redeem: matches the plaintext against the stored hash, marks the row consumed inside a single transaction, and returns the row to the caller so they can mint a session. Returns ErrBootstrapInvalid for any miss — not-found, expired, already-consumed — so the wire response can't be used to probe which tokens exist.
The conditional UPDATE mirrors the SQLite pattern documented in `feedback_sqlite_transactions.md`: a SELECT alone races with another consumer; a `UPDATE … WHERE consumed_at IS NULL` + checking RowsAffected == 1 is atomic on the writer thread.
func (*Registry) ConsumeInvitation ¶
func (r *Registry) ConsumeInvitation(ctx context.Context, tokenHash, newActorID, newKeyID string, pub ed25519.PublicKey, label, kind string) (*ConsumeResult, error)
ConsumeInvitation atomically redeems a token. The whole sequence lives in one BEGIN IMMEDIATE transaction so two concurrent consumers can't both succeed.
Sequence:
- BEGIN IMMEDIATE — write lock up front
- Look up actor_keys by public_key (active rows only). If found, the consumer is an EXISTING principal — we'll bind a new membership to them instead of minting a duplicate.
- UPDATE actor_invitations SET consumed_at,consumed_by — guarded by the WHERE clause. consumed_by records the EXISTING actor_id when reusing, otherwise the freshly-minted newActorID.
- Re-read the invitation row to recover capabilities + tenant_id.
- If not reusing: INSERT actors + INSERT actor_keys + INSERT actor_capabilities*N (the chassis-wide back-compat row). If reusing: skip — the principal already has all that.
- Upsert membership(actor_id, invitation.tenant_id, invitation.capabilities) — the tenant grant is the point of this whole flow.
- COMMIT
If anything past step 3 fails, the tx rolls back and the burn is reverted — the invitation goes back to "live" and a retry can succeed. ErrKeyAlreadyEnrolled bubbles up from a race where another consumer enrolled the key between our lookup and our insert; the caller's invitation row is rolled back too.
func (*Registry) CreateActor ¶
CreateActor inserts a new actor row. The caller is responsible for generating actor_id (use chassis/hxid). Capability rows are written separately via GrantCapability so this stays a thin façade.
func (*Registry) CreateBootstrap ¶
func (r *Registry) CreateBootstrap(ctx context.Context, actorID, tenantID string, caps []string, label string, ttl time.Duration) (plaintext string, expiresAt time.Time, err error)
CreateBootstrap mints a new exchange token for the given actor + tenant + capability snapshot. Returns the plaintext token (which the caller surfaces to the user exactly once) and its expiry.
Multiple outstanding bootstraps for the same actor are allowed — the short TTL bounds the blast radius and forbidding it would break retry flows.
func (*Registry) CreateInvitation ¶
func (r *Registry) CreateInvitation(ctx context.Context, inv Invitation) error
CreateInvitation inserts an invitation row. The caller is responsible for hashing the token and assigning a hxid invitation_id. Capabilities must already be set (no defaulting here — that's policy, not storage). TenantID defaults to DefaultTenantID when empty so callers that haven't yet migrated to tenant-aware invites keep working against the seeded default tenant.
func (*Registry) CreateKey ¶
CreateKey inserts an actor_keys row. PublicKey must be a valid ed25519.PublicKey (32 bytes).
Returns ErrKeyAlreadyEnrolled if the public_key is already bound to a non-revoked actor_keys row (enforced by the partial UNIQUE index from migration 0009). Callers should pre-check via LookupKeyByPublicKey when they want to branch on the existing row; CreateKey detects the race and surfaces the same typed error.
func (*Registry) CreateMembership ¶
func (r *Registry) CreateMembership(ctx context.Context, m Membership) (*Membership, error)
CreateMembership upserts an (actor, tenant) membership. v1 stores the capability set as a JSON array; passing the same (actor, tenant) pair twice REPLACES the capabilities — useful for "add me to this tenant with these capabilities, even if I'm already a member."
Returns the freshly-written membership so callers can echo it without a follow-up read. TenantSlug is filled via the registry's TenantLookup if one is configured.
func (*Registry) CreateSession ¶
func (r *Registry) CreateSession(ctx context.Context, b *Bootstrap, ua, ip string, ttl time.Duration) (*Session, error)
CreateSession turns a successfully-consumed Bootstrap into a session. Capabilities are snapshotted from the bootstrap row, not re-resolved from memberships, so a capability change after this point requires a session revoke + fresh login to take effect.
func (*Registry) GetSession ¶
GetSession reads a session by id. Returns ErrNotFound when the session doesn't exist; returns a Session whose IsValid() is false when the row exists but is revoked or expired (caller decides how to surface that — middleware treats it as 401).
func (*Registry) HasAnyActiveActor ¶
HasAnyActiveActor reports whether at least one actors row has revoked_at IS NULL. The admin server uses this during startup to decide whether to auto-generate a first-boot dev-enroll secret; it also serves as the burn-after-use gate on `/auth/dev/enroll`.
Cheap by design: LIMIT 1, primary-key scan on a small table.
func (*Registry) ListActors ¶
ListActors returns every row in actors, newest first. v1 returns them all; pagination ships in v2 when there's a real need.
func (*Registry) ListInvitations ¶
func (r *Registry) ListInvitations(ctx context.Context) ([]Invitation, error)
ListInvitations returns every invitation row, newest first. Status derivation happens at the HTTP layer — this method is pure storage.
func (*Registry) ListMembershipsForActor ¶
func (r *Registry) ListMembershipsForActor(ctx context.Context, actorID string) ([]Membership, error)
ListMembershipsForActor returns every active membership for an actor. Slugs are resolved via the registry's TenantLookup; rows are sorted by resolved slug (or by tenant_id when slug resolution is unavailable) so the caller sees a stable order.
Two-pass shape: drain the membership rows fully *before* calling fillSlug. The tenants table lives in runtime.db (a different *sql.DB in production, but possibly the same handle under test); resolving slugs while the outer Rows is still open would block on a single- connection pool, since the cursor is holding the only connection.
func (*Registry) ListMembershipsForTenant ¶
func (r *Registry) ListMembershipsForTenant(ctx context.Context, tenantID string) ([]Membership, error)
ListMembershipsForTenant returns every active member of a tenant. Used by `txco auth tenant members <slug>` and for admin auditing. Slug is resolved once before the loop (all rows share the same tenant) and then stamped onto each row.
func (*Registry) ListSessions ¶
ListSessions returns every session for a tenant — both active and revoked/expired — newest first. The admin UI uses this to render a "manage sessions" view; the CLI uses it for `txco auth sessions list`.
func (*Registry) LoadMembership ¶
func (r *Registry) LoadMembership(ctx context.Context, actorID, tenantID string) (*Membership, error)
LoadMembership returns a single (actor, tenant) row. The slug is resolved via the registry's TenantLookup; if no resolver is set or the lookup fails, TenantSlug is left empty. Returns ErrNotFound when no active membership exists.
func (*Registry) LookupActor ¶
LookupActor reads a single actors row by primary key.
func (*Registry) LookupInvitationByTokenHash ¶
func (r *Registry) LookupInvitationByTokenHash(ctx context.Context, tokenHash string) (*Invitation, error)
LookupInvitationByTokenHash reads a single invitation row by its (already-hashed) token. Used by the unsigned consume endpoint's auditing log line; the actual atomic consume goes through ConsumeInvitation. Returns ErrNotFound if the row is missing.
func (*Registry) LookupKeyByPublicKey ¶
LookupKeyByPublicKey returns the active actor_keys row whose public_key matches the given bytes, or ErrNotFound when no row (active or otherwise) carries that key. Used by /auth/dev/enroll to refuse re-enrolment and by /auth/invitations/consume to bind new memberships to an existing principal instead of minting a duplicate actor.
func (*Registry) RevokeActor ¶
RevokeActor sets revoked_at on an actor and cascades to all its keys. Capability rows are left as-is — the actor's revocation is what matters for auth decisions.
func (*Registry) RevokeActorSessions ¶
RevokeActorSessions revokes every session belonging to an actor. Called from the existing actor-revoke handler so revoking a user kicks them out of every browser they're signed into.
func (*Registry) RevokeInvitation ¶
RevokeInvitation marks an invitation as revoked. Idempotent: revoking an already-revoked / already-consumed invitation is a no-op (the WHERE clause filters those out).
func (*Registry) RevokeKey ¶
RevokeKey sets revoked_at on a single key. Idempotent: revoking an already-revoked key is a no-op (the column is set to the same now).
func (*Registry) RevokeMembership ¶
RevokeMembership soft-deletes a membership by setting revoked_at. Idempotent: already-revoked rows are left alone.
func (*Registry) RevokeSession ¶
RevokeSession marks a session revoked. Idempotent — calling it on an already-revoked or non-existent session is a no-op (returns nil), which simplifies UI logout flows that may double-fire.
func (*Registry) SetActorSuperAdmin ¶
SetActorSuperAdmin flips the actors.super_admin flag. v1 callers only set this during bootstrap (the first enrolled actor) or via a future admin command. Idempotent.
type Session ¶
type Session struct {
SessionID string
ActorID string
TenantID string
Capabilities []string
UA string
IP string
CreatedAt time.Time
ExpiresAt time.Time
RevokedAt *time.Time
RevokedBy string
LastSeenAt time.Time
}
Session is one row of browser_sessions.
type TenantLookup ¶
TenantLookup resolves a tenant_id to its slug. Satisfied by chassis/tenants.Store via the adapter constructed in chassis/server/admin/server.go. Function type rather than interface because there's exactly one operation and zero state.