Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions go/internal/auth/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
8 changes: 4 additions & 4 deletions go/internal/store/migrations/0001_init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go/internal/store/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions go/internal/store/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 12 additions & 6 deletions go/internal/store/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,24 +87,30 @@ 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 (
// SubjectAccount is a user or agent account token subject.
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
}

Expand Down
Loading