From 4f6311ac64339c5c8873ac17e0b733d2d4aa1d80 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 5 Sep 2026 19:55:00 -0400 Subject: [PATCH 1/2] feat(store): admit SubjectService token principal (enum half) (RIG-2863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a third token-subject principal class, `SubjectService = 2`, for first-party supervised compute tiers (the LLM gateway, a future MCP gateway) that authenticate back to the Server. This is the enum half of the frozen SubjectService addendum — the urgent, dependency-free slice, since the enum number and the `tokens.subject_kind` CHECK are painful to change once a non-disposable database has applied v1. - T1: `SubjectService SubjectKind = 2` (append-only; 0/1 unchanged), seal comment retargeted to name three kinds and cite this record, and the `Subject`/`Subject.ID` docs widened off the two-kind enumeration onto the third id space (`types.go`). - T2: widen the `tokens.subject_kind` CHECK to `IN (0, 1, 2)` in `0001_init.sql` (edited in place per the pre-GA disposable-DB posture) plus its header comment; sqlc regenerates byte-identically (no `internal/store/db` drift). - T3: track the gosec G115 waiver's constrained-set text (`0/1` → `0/1/2`) in `tokens.go`. - T5: the 3-kind cross-door pgtest matrix in `auth/token_test.go` (a service token resolves only at `want=SubjectService`, ErrWrongKind at every other door and for account/Runner tokens at the service door) plus two store round-trips proving the CHECK admits 2 and stays a closed set at exactly {0, 1, 2} (an out-of-range kind fails 23514, which falls through to the bare wrap — no new sentinel). The service-door mount (T4), token issuance, and tenant posture are explicitly out of scope: they land with the RIG-2863 service surface (RIG-1715 T2), gated on the secretspec v0.20 bump (RIG-3320). Spec-impact: none (executes the frozen record; no ledger/design edit). Refs RIG-2863 Co-authored-by: Matt Wilkinson --- go/internal/auth/token_test.go | 71 ++++++++++++++++++++++ go/internal/store/migrations/0001_init.sql | 7 ++- go/internal/store/tokens.go | 2 +- go/internal/store/tokens_test.go | 34 +++++++++++ go/internal/store/types.go | 18 ++++-- 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/go/internal/auth/token_test.go b/go/internal/auth/token_test.go index 056434e70..f2d544628 100644 --- a/go/internal/auth/token_test.go +++ b/go/internal/auth/token_test.go @@ -154,3 +154,74 @@ func TestCrossDoorTokenIsWrongKind(t *testing.T) { } }) } + +// TestServiceTokenCrossDoorMatrix extends the cross-door rejection above to the +// third subject kind (SubjectService, the first-party supervised compute tier). +// The matrix proves the ONE shared resolver gates on `want` against all three +// kinds, not a hard-coded pair: a service token authenticates only at +// want=SubjectService, and is ErrWrongKind at each of the other two wants; +// symmetrically, an account token and a Runner token are each ErrWrongKind at +// want=SubjectService. Never a success, never ErrTokenNotFound — the hash +// resolves in every case, so only the kind gate can be what fails. +func TestServiceTokenCrossDoorMatrix(t *testing.T) { + ctx := context.Background() + st, admin, _ := openTestStore(t) + + // A service-kind row put directly into the store: SubjectService issuance is + // a later slice, and this test needs only a live service-subject row to + // present at each door. hashToken agrees with IssueAccountToken on the key. + const serviceID = "llm-gateway" + serviceToken := "c2VydmljZS10b2tlbg" // base64url-shaped, never account-issued + if err := st.PutTokenHash(ctx, hashToken(serviceToken), store.Subject{Kind: store.SubjectService, ID: serviceID}); err != nil { + t.Fatalf("PutTokenHash(service): %v", err) + } + + t.Run("service token wanted as service resolves", func(t *testing.T) { + subj, err := ResolveToken(ctx, st, serviceToken, store.SubjectService) + if err != nil { + t.Fatalf("a live service token must resolve at the service door: %v", err) + } + if subj.Kind != store.SubjectService { + t.Fatalf("resolved kind = %d, want SubjectService", subj.Kind) + } + if subj.ID != serviceID { + t.Fatalf("resolve must return the service the token was stored for: got %q, want %q", subj.ID, serviceID) + } + }) + + t.Run("service token wanted as account is WrongKind", func(t *testing.T) { + _, err := ResolveToken(ctx, st, serviceToken, store.SubjectAccount) + if !errors.Is(err, ErrWrongKind) { + t.Fatalf("a service token at the account door must be ErrWrongKind, got %v", err) + } + }) + + t.Run("service token wanted as runner is WrongKind", func(t *testing.T) { + _, err := ResolveToken(ctx, st, serviceToken, store.SubjectRunner) + if !errors.Is(err, ErrWrongKind) { + t.Fatalf("a service token at the Runner door must be ErrWrongKind, got %v", err) + } + }) + + t.Run("account token wanted as service is WrongKind", func(t *testing.T) { + accountToken, err := IssueAccountToken(ctx, st, admin) + if err != nil { + t.Fatalf("IssueAccountToken: %v", err) + } + _, err = ResolveToken(ctx, st, accountToken, store.SubjectService) + if !errors.Is(err, ErrWrongKind) { + t.Fatalf("an account token at the service door must be ErrWrongKind, got %v", err) + } + }) + + t.Run("runner token wanted as service is WrongKind", func(t *testing.T) { + runnerToken := "cnVubmVyLWZvci1zZXJ2aWNl" // base64url-shaped, never account-issued + if err := st.PutTokenHash(ctx, hashToken(runnerToken), store.Subject{Kind: store.SubjectRunner, ID: "some-runner"}); err != nil { + t.Fatalf("PutTokenHash(runner): %v", err) + } + _, err := ResolveToken(ctx, st, runnerToken, store.SubjectService) + if !errors.Is(err, ErrWrongKind) { + t.Fatalf("a Runner token at the service door must be ErrWrongKind, got %v", err) + } + }) +} diff --git a/go/internal/store/migrations/0001_init.sql b/go/internal/store/migrations/0001_init.sql index 1c107cbb9..c064bb801 100644 --- a/go/internal/store/migrations/0001_init.sql +++ b/go/internal/store/migrations/0001_init.sql @@ -370,11 +370,12 @@ CREATE TABLE channel_pins ( -- ── Token hashes ──────────────────────────────────────────────────────────── -- Subject-typed token store (design.md:1175-1183): the SHA-256 hash is the PK -- (the plaintext token is returned once and never stored). subject_kind is --- 0 account / 1 runner; subject_id spans both id spaces. revoked_at is set on --- RevokeToken so ResolveTokenHash can distinguish revoked from never-issued. +-- 0 account / 1 runner / 2 service; subject_id spans those id spaces. +-- revoked_at is set on RevokeToken so ResolveTokenHash can distinguish +-- revoked from never-issued. CREATE TABLE tokens ( hash BYTEA PRIMARY KEY, - subject_kind SMALLINT NOT NULL CHECK (subject_kind IN (0, 1)), + subject_kind SMALLINT NOT NULL CHECK (subject_kind IN (0, 1, 2)), subject_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ diff --git a/go/internal/store/tokens.go b/go/internal/store/tokens.go index 031b90980..7cc791a8f 100644 --- a/go/internal/store/tokens.go +++ b/go/internal/store/tokens.go @@ -17,7 +17,7 @@ func (s *Store) PutTokenHash(ctx context.Context, hash [32]byte, subj Subject) e } if err := s.q.InsertTokenHash(ctx, db.InsertTokenHashParams{ Hash: hash[:], - SubjectKind: int16(subj.Kind), //nolint:gosec // G115: SubjectKind is a CHECK-constrained 0/1 enum (tokens.subject_kind), always within int16 + SubjectKind: int16(subj.Kind), //nolint:gosec // G115: SubjectKind is a CHECK-constrained 0/1/2 enum (tokens.subject_kind), always within int16 SubjectID: subj.ID, }); err != nil { if pgErrIs(err, pgUniqueViolation) { diff --git a/go/internal/store/tokens_test.go b/go/internal/store/tokens_test.go index 53c68ba75..ae86cdb96 100644 --- a/go/internal/store/tokens_test.go +++ b/go/internal/store/tokens_test.go @@ -38,6 +38,40 @@ func TestPutResolveRoundTripCarriesKind(t *testing.T) { } } +// A SubjectService row persists and its kind round-trips: the store-level proof +// that the widened tokens.subject_kind CHECK admits 2. +func TestPutResolveRoundTripCarriesServiceKind(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + hash := tokenHash("service-token") + want := Subject{Kind: SubjectService, ID: "llm-gateway"} + if err := s.PutTokenHash(ctx, hash, want); err != nil { + t.Fatalf("PutTokenHash(service): %v", err) + } + got, err := s.ResolveTokenHash(ctx, hash) + if err != nil { + t.Fatalf("ResolveTokenHash: %v", err) + } + if got != want { + t.Fatalf("resolved = %+v, want %+v (service kind must round-trip)", got, want) + } +} + +// The widened CHECK is still a CLOSED set of exactly {0, 1, 2}: an out-of-range +// kind is rejected by the database, not silently stored. The subject id is +// non-empty and the hash fresh, so the CHECK violation is the sole possible +// failure source — hence the assertion is only that it failed. A 23514 has no +// typed store sentinel by design; it falls through to the bare wrap. +func TestPutUnknownSubjectKindViolatesCheck(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + if err := s.PutTokenHash(ctx, tokenHash("kind-3"), Subject{Kind: SubjectKind(3), ID: "nope"}); err == nil { + t.Fatal("PutTokenHash with an out-of-range subject kind must fail the CHECK, got nil") + } +} + func TestTokenKindsDoNotCollideOnSameID(t *testing.T) { ctx := context.Background() s := newTestStore(t) diff --git a/go/internal/store/types.go b/go/internal/store/types.go index fd7368088..3e3f9137c 100644 --- a/go/internal/store/types.go +++ b/go/internal/store/types.go @@ -87,8 +87,8 @@ const ( // subject share the token store but never collide — the prefix-separation the // auth layer (T4) depends on. A resolved token carries its kind, so a door can // reject a cross-kind token (a Runner token on CompassService/CommsService, an -// account token on RunnerService). Sealed to exactly these two (design.md: -// 1175-1183). +// account token on RunnerService). Sealed to exactly these three +// (docs/designs/server/compass-service-subject-principal.md). type SubjectKind int32 const ( @@ -96,15 +96,21 @@ const ( SubjectAccount SubjectKind = 0 // SubjectRunner is a provisioned-Runner token subject. SubjectRunner SubjectKind = 1 + // SubjectService is a first-party supervised compute tier (LLM gateway, + // future MCP gateway) authenticating back to the Server. One class for all + // tiers; instances are distinguished by Subject.ID, isolated per-surface. + SubjectService SubjectKind = 2 ) // Subject is a token's authenticated principal: its kind plus the id of the -// account or Runner it authenticates. ResolveTokenHash returns it with the kind -// set so a cross-kind token is rejected at the door, not silently accepted. +// account, Runner, or service it authenticates. ResolveTokenHash returns it +// with the kind set so a cross-kind token is rejected at the door, not +// silently accepted. type Subject struct { Kind SubjectKind - // ID is the AccountID (SubjectAccount) or the Runner id (SubjectRunner), as - // a bare string because it spans two id spaces. + // ID is the AccountID (SubjectAccount), the Runner id (SubjectRunner), or a + // stable service name such as "llm-gateway" (SubjectService), as a bare + // string because it spans those id spaces. ID string } From 10c5fa7186a94d1e7814848b15135d7ffea8c889 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 5 Sep 2026 20:40:49 -0400 Subject: [PATCH 2/2] fix(store): restore token-comment line count; complete cross-door matrix (RIG-2863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review R1 follow-up on the SubjectService enum half. - **Migration comment (medium):** the T2 header-comment edit had grown the `tokens` block from 4 lines to 5, shifting every line below `0001_init.sql:373` by +1 and silently invalidating 8 line-pinned citations in three frozen design records (at-rest-encryption, dogfood-e2e-steer-deliver-seam, mention-offline-redelivery). Rewrapped the comment back to 4 lines with the full semantics intact, reverting the shift and leaving all 8 citations correct with zero edits to the frozen records (per docs/designs/CONTRIBUTING.md §5 this is a link-integrity concern; the count-preserving rewrap avoids touching the records at all). - **Cross-door matrix (low):** added the runner positive diagonal (`runner token wanted as runner resolves`) to `TestServiceTokenCrossDoorMatrix`, closing the 3x3 — a resolvable token of each kind now succeeds at its own door, pinning the `want` comparison against all three values rather than only the two rejection axes. No runtime behavior change. gofmt/build/vet clean; sqlc-drift byte-identical; sql-migration-gate green; store + auth pgtests pass (matrix runs all 6 subtests). Refs RIG-2863 Spec-impact: none Ledger-impact: none Co-authored-by: Matt Wilkinson --- go/internal/auth/token_test.go | 23 ++++++++++++++++++++++ go/internal/store/migrations/0001_init.sql | 7 +++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/go/internal/auth/token_test.go b/go/internal/auth/token_test.go index f2d544628..adaa9714c 100644 --- a/go/internal/auth/token_test.go +++ b/go/internal/auth/token_test.go @@ -224,4 +224,27 @@ func TestServiceTokenCrossDoorMatrix(t *testing.T) { t.Fatalf("a Runner token at the service door must be ErrWrongKind, got %v", err) } }) + + t.Run("runner token wanted as runner resolves", func(t *testing.T) { + // The runner positive diagonal: the account diagonal is covered by + // TestIssueThenResolveRoundTripsToTheIssuedAccount and the service one + // above, so this closes the 3x3 — a resolvable token of each kind + // succeeds at its own door, pinning the `want` comparison against all + // three values rather than only the two rejection axes. + runnerToken := "cnVubmVyLWRpYWdvbmFs" // base64url-shaped, never account-issued + const runnerID = "diagonal-runner" + if err := st.PutTokenHash(ctx, hashToken(runnerToken), store.Subject{Kind: store.SubjectRunner, ID: runnerID}); err != nil { + t.Fatalf("PutTokenHash(runner): %v", err) + } + subj, err := ResolveToken(ctx, st, runnerToken, store.SubjectRunner) + if err != nil { + t.Fatalf("a live Runner token must resolve at the Runner door: %v", err) + } + if subj.Kind != store.SubjectRunner { + t.Fatalf("resolved kind = %d, want SubjectRunner", subj.Kind) + } + if subj.ID != runnerID { + t.Fatalf("resolve must return the runner the token was stored for: got %q, want %q", subj.ID, runnerID) + } + }) } diff --git a/go/internal/store/migrations/0001_init.sql b/go/internal/store/migrations/0001_init.sql index c064bb801..30b540d81 100644 --- a/go/internal/store/migrations/0001_init.sql +++ b/go/internal/store/migrations/0001_init.sql @@ -369,10 +369,9 @@ CREATE TABLE channel_pins ( -- ── Token hashes ──────────────────────────────────────────────────────────── -- Subject-typed token store (design.md:1175-1183): the SHA-256 hash is the PK --- (the plaintext token is returned once and never stored). subject_kind is --- 0 account / 1 runner / 2 service; subject_id spans those id spaces. --- revoked_at is set on RevokeToken so ResolveTokenHash can distinguish --- revoked from never-issued. +-- (the plaintext token is returned once and never stored). subject_kind is 0 +-- account / 1 runner / 2 service; subject_id spans those id spaces. revoked_at +-- is set on RevokeToken so ResolveTokenHash tells revoked from never-issued. CREATE TABLE tokens ( hash BYTEA PRIMARY KEY, subject_kind SMALLINT NOT NULL CHECK (subject_kind IN (0, 1, 2)),