Skip to content

feat(auth): token registry 4/4 — refuse unregistered tokens and unknown subjects - #1406

Draft
manas-narra wants to merge 1 commit into
feat/token-registry-3-rotatefrom
feat/token-registry-4-enforce
Draft

manas-narra wants to merge 1 commit into
feat/token-registry-3-rotatefrom
feat/token-registry-4-enforce

Conversation

@manas-narra

@manas-narra manas-narra commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Updated 29 Oct (Swaroop's ask): the registry's kind is now admin | merchant (it was s2s | merchant). admin = a token from POST /auth/s2s/token; merchant = a token from POST /merchant with issue_token. Only the name changed: no behaviour, table or migration-number change. "S2S" below still names the POST /auth/s2s/token route.

Rebased 29 Oct onto release c81dfb2 (head ba75fdc1). release took migration 085 (WhatsApp delivery receipts), so this stack's migrations moved up by one (086–089); the numbers in this description are updated, nothing else in the change moved. Re-run on this commit: pytest tests/crm 1647 passed, 15 skipped (as CI) / 1662 passed with the test database (all migrations applied in order to a fresh Postgres) · pytest tests outside tests/crm 3269 passed · pyrefly 0 · black / isort / autoflake clean · check_migrations + check_crm_boundaries OK. The test counts further down are from when the PR was first raised.

⚠️ DO NOT MERGE YET. Merge only after #1404 (2/4) has run in prod and this replica query has read 0 for 7 days in a row:

SELECT count(*) FROM token_registry
WHERE source = 'backfilled' AND created_at > now() - interval '7 days';

Also, before merging: check GET /auth/tokens for tokens nobody owns, rotate or revoke them, and reconcile against the 30-day RBAC token verified … jti: log inventory (see below).

Stacked on #1405 (3/4) → #1404 (2/4) → #1403 (1/4). Base: feat/token-registry-3-rotate.

Why

2/4 runs the registry in observe mode. A valid long-lived token the registry has never seen is registered as backfilled and let through, which is how the tokens actually in use get found. 3/4 does the same for a token whose user or merchant no longer exists: logged, but allowed.

Once the observe period has found every token in use, letting unknown tokens through is the gap itself. This PR closes it.

The stack

PR What Behaviour change
1/4 #1403 The table; jti + a row at mint none for existing tokens
2/4 #1404 The registry check in every verifier (observe); admin list + revoke revoked tokens refused
3/4 #1405 Rotate; deactivation stops tokens; login cut-off rotation; inactive users/merchants refused
4/4 (this) Enforce unregistered long-lived tokens and unknown subjects refused

What this PR does

1. An unregistered long-lived token is refused (401).

  • authenticate looks the token up by hash. No row means 401, logged with the subject, username and jti, and nothing is written.
  • The find-or-register path from 2/4 (find_or_register_seen_token and the insert's ON CONFLICT DO NOTHING option) is deleted.
  • A "not found" isn't cached, so a token minted or rotated on another pod a moment ago is found on its next use. Found rows are cached for 30 s as before.

2. A token whose subject no longer exists is refused (401). This applies to a deleted user, or merchant:<id> for a deleted merchant. Before this PR it was logged and allowed.

What doesn't change

  • Revoke, rotate (with grace), deactivation, the login cut-off, and last_seen.
  • 503 when the registry or subject can't be read.
  • Short-lived tokens (login, launch, signup) still need no registry row. They are checked only against their subject.
  • Rows written in the observe period stay (source = backfilled). The vocabulary keeps the word.

Why "the log backfill" doesn't create rows

The RBAC token verified for user: … jti: … log line holds the username and scopes, not the token, so no hash can be computed from it. Rows come only from the mint (1/4), from observe mode (2/4) and from rotation (3/4).

The 30 days of logs, joined to the load-balancer logs for IP and path, are the inventory for the reconcile. They tell us who uses tokens, so that a token used less often than the observe window (a monthly job, say) is found and rotated before this merges. Otherwise this PR would refuse it.

Rollback

There's no runtime switch. A switch to stop refusing unknown tokens is the kind of bypass the repo's "fail closed, no bypass flags" rule forbids. Rollback is reverting this commit and redeploying. 2/4's observe mode comes back as it was.

Tests

Updated in tests/breeze_buddy/test_token_registry_observe.py and test_token_registry_rotate.py. The 142 token tests across the 5 files pass.

  • An unregistered long-lived token → 401, with or without a jti, and nothing is stamped for it.
  • "Not found" isn't cached: unknown, then registered → the second use passes.
  • A subject that no longer exists → 401, for login and S2S tokens alike.
  • The mint insert never tolerates a conflict. No ON CONFLICT is left anywhere.
  • Removed (the code is gone): the four find-or-register accessor tests, "unknown token registered with its own claims", "merchant subject registers as kind merchant", and "jti kept as the row id".

Mutation check: 7/7 killed.

  • an unknown token let through (two ways);
  • "not found" cached;
  • an unknown subject allowed;
  • a registry error passing;
  • a revoked token passing;
  • the mint insert tolerating conflicts.

On a throwaway Postgres 17 with the network blocked, through the real routes: 9/9, 0 rows left.

  • A login token: 200 with no row.
  • An unregistered 90-day token: 401, and no backfilled row written.
  • A minted S2S token: 200. Its rotated successor: 200, and the old one 401.
  • A minted merchant token: 200.
  • A deleted user's S2S token: 401.
  • Registry unreadable: 503.

Gates

Check Result
pytest on the 5 token test files 142 passed
pytest tests/crm 1593 passed, 15 skipped
pytest tests (full) 4829 passed, 22 skipped, 1 xfailed, 0 failed
pyrefly check 0 errors
black / isort / autoflake (touched files) clean
scripts/check_migrations.py OK, 89 (no new migration)
scripts/check_crm_boundaries.py OK

CI runs only tests/crm, so the token tests above are run by hand.

@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@manas-narra
manas-narra force-pushed the feat/token-registry-4-enforce branch from e6078da to ba75fdc Compare October 29, 2026 02:58
@manas-narra
manas-narra added this pull request to stack #1435 October 29, 2026 04:07
…wn subjects

Builds on 3/4. Ends the observe period of 2/4:

- a long-lived RBAC token the registry doesn't hold is refused (401)
  instead of being registered as 'backfilled' and let through; the
  find-or-register path is deleted. A not-found answer isn't cached, so a
  token minted or rotated on another pod is found on its next use
- a token whose user or merchant no longer exists is refused (401)
  instead of logged and allowed
- unchanged: revoke, rotate, deactivation, the login cut-off, last_seen,
  503 when the registry can't be read; short-lived tokens need no row

Merge only after 2/4 has run in prod and no new 'backfilled' rows have
appeared for 7+ days.
@manas-narra
manas-narra force-pushed the feat/token-registry-4-enforce branch from ba75fdc to 9ba1d4e Compare October 29, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant