diff --git a/go/internal/auth/token_test.go b/go/internal/auth/token_test.go index 056434e7..adaa9714 100644 --- a/go/internal/auth/token_test.go +++ b/go/internal/auth/token_test.go @@ -154,3 +154,97 @@ 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) + } + }) + + 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 1c107cbb..30b540d8 100644 --- a/go/internal/store/migrations/0001_init.sql +++ b/go/internal/store/migrations/0001_init.sql @@ -369,12 +369,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. +-- (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)), + 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 031b9098..7cc791a8 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 53c68ba7..ae86cdb9 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 fd736808..3e3f9137 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 }