registry

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: May 29, 2026 License: MPL-2.0 Imports: 14 Imported by: 0

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

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

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

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

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

func HashToken

func HashToken(token string) string

HashToken returns the canonical token_hash form used in the schema: lowercase hex of SHA-256(token). Exported so handlers and tests share the same digest function.

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

func DialectForDSN(dsn string) Dialect

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.

func (Key) IsRevoked

func (k Key) IsRevoked() bool

IsRevoked reports whether the key has been revoked (revoked_at IS NOT NULL).

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:

  1. BEGIN IMMEDIATE — write lock up front
  2. 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.
  3. 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.
  4. Re-read the invitation row to recover capabilities + tenant_id.
  5. 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.
  6. Upsert membership(actor_id, invitation.tenant_id, invitation.capabilities) — the tenant grant is the point of this whole flow.
  7. 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

func (r *Registry) CreateActor(ctx context.Context, a Actor) error

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

func (r *Registry) CreateKey(ctx context.Context, k Key) error

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

func (r *Registry) GetSession(ctx context.Context, sessionID string) (*Session, error)

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

func (r *Registry) HasAnyActiveActor(ctx context.Context) (bool, error)

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

func (r *Registry) ListActors(ctx context.Context) ([]Actor, error)

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

func (r *Registry) ListSessions(ctx context.Context, tenantID string) ([]Session, error)

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

func (r *Registry) LookupActor(ctx context.Context, actorID string) (*Actor, error)

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

func (r *Registry) LookupKey(ctx context.Context, keyID string) (*Key, error)

LookupKey reads a single actor_keys row by primary key.

func (*Registry) LookupKeyByPublicKey

func (r *Registry) LookupKeyByPublicKey(ctx context.Context, pub ed25519.PublicKey) (*Key, error)

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

func (r *Registry) RevokeActor(ctx context.Context, actorID string) error

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

func (r *Registry) RevokeActorSessions(ctx context.Context, actorID, by string) error

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

func (r *Registry) RevokeInvitation(ctx context.Context, invitationID string) error

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

func (r *Registry) RevokeKey(ctx context.Context, keyID string) error

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

func (r *Registry) RevokeMembership(ctx context.Context, actorID, tenantID string) error

RevokeMembership soft-deletes a membership by setting revoked_at. Idempotent: already-revoked rows are left alone.

func (*Registry) RevokeSession

func (r *Registry) RevokeSession(ctx context.Context, sessionID, by string) error

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

func (r *Registry) SetActorSuperAdmin(ctx context.Context, actorID string, super bool) error

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.

func (*Registry) TouchSession

func (r *Registry) TouchSession(ctx context.Context, sessionID string, now time.Time) error

TouchSession updates last_seen_at. Called from the middleware on every authenticated request. Cheap: indexed primary-key write of a single timestamp.

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.

func (*Session) IsValid

func (s *Session) IsValid(now time.Time) bool

IsValid reports whether the session is currently usable: not revoked and not past its absolute expiry. The middleware calls this with time.Now() at the start of every request that carries a session cookie.

type TenantLookup

type TenantLookup func(ctx context.Context, tenantID string) (string, error)

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.

Jump to

Keyboard shortcuts

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