Skip to content

fix: add subscribed_tools custom access-token hook (#288) - #289

Merged
ckrough merged 2 commits into
mainfrom
issue/288
Jul 13, 2026
Merged

fix: add subscribed_tools custom access-token hook (#288)#289
ckrough merged 2 commits into
mainfrom
issue/288

Conversation

@ckrough

@ckrough ckrough commented Jul 13, 2026

Copy link
Copy Markdown
Member

Closes #288

Summary

Subscription enforcement was half-implemented: the public.subscriptions table and the backend require_subscription gate existed, but the Supabase custom access-token hook that projects the subscribed_tools claim into the JWT was never built. With the claim never present, require_subscription(...) 403'd every user on every module-gated route.

This adds the token-side half:

  • New migration apps/stacker/supabase/migrations/20260713000000_custom_access_token_hook.sql: defines public.custom_access_token_hook(event jsonb) returns jsonb, which aggregates module_id from public.subscriptions for rows with status in ('active','trialing') into a subscribed_tools array claim (empty array when no rows, never null, never an error), merges it into the event claims, and returns the event. Grants execute to supabase_auth_admin, revokes it from authenticated/anon/public, adds a column-scoped select (user_id, module_id, status) grant, and an RLS policy scoped to supabase_auth_admin (the table's existing auth.uid()-keyed policy never matches the hook's own session).
  • apps/stacker/supabase/config.toml: enables [auth.hook.custom_access_token] pointing at the function so local dev issues tokens with the claim.
  • docs/subscriptions.md: corrected to describe the implemented hook and to document the hosted enablement step (tracked as Configure production SMTP (Cloudflare Email Service) for Supabase auth emails #157).

Design note: the function is security invoker (not security definer), following Supabase's documented hook grant/RLS pattern. security definer on a migration-created function runs as postgres (bypassrls) and would silently sidestep RLS on public.subscriptions; the explicit role-scoped grant + policy is the safer path.

Pipeline phases

  • 🟢 Plan / design triage — own plan, design pass skipped (complexity score 2: bug, ~3 files, follows existing subscriptions-migration pattern, no new module).
  • 🟢 Implement — Sonnet implementer; verified hook contract against current Supabase docs first (acceptance criterion Set up stacker CI pipeline #7). No new test files: the monorepo has no SQL/migration test harness and Postgres tests are CI-delegated.
  • 🟢 Test-validation — 0 blocking, 0 advisory; no existing tests orphaned (no application code changed).
  • 🟢 Review-gate — APPROVE (1 cycle); the security-hardening delta was re-reviewed and also APPROVE.
  • 🟢 Simplify — NO-OP (the only code is a minimal 3-statement plpgsql function; nothing to reuse, simplify, or make more efficient).
  • 🟢 Gates — configured test + lint both exit 0 (see Test results).
  • 🟡 Security review — 3 LOW findings: 2 remediated in this PR (least-privilege column-scoped grant; NULL-safe coalesce on the claims read), 1 accepted as inherent design risk (claim-based entitlement lags token TTL, bounded by the 1h access-token lifetime).
  • 🟢 PR opened — non-draft PR referencing Implement subscribed_tools custom access-token hook (subscription gate 403s all users) #288.

Test results

Both configured gate commands were run in a clean worktree and exit 0:

  • test: (cd services/retriever && uv sync && uv run python -m pytest tests/ --ignore=tests/integration) && (cd apps/stacker && npm ci && npm run build)exit 0 (371 passed, 10 deselected; SvelteKit build ✓).
  • lint: retriever ruff check + ruff format --check + mypy --strict; petdata ruff + mypy + bandit; stacker npm run checkexit 0 (all ruff checks passed, mypy clean on both services, bandit no issues, svelte-check 0 errors / 0 warnings).

The SQL/config/docs diff is not exercised by these gates (no SQL harness in-repo; petdata Postgres tests are CI-delegated); the migration was additionally validated by executing it end-to-end against a disposable Postgres instance (column grant blocks select *, coalesce fixes the missing-claims case).

Eval scores

No evals configured in this repo.

Operator handoff (out of code scope)

Two acceptance criteria require production access + a deploy and cannot be completed in a worktree:

  1. Enable the hook on the hosted evermore-auth project: Auth -> Hooks -> Custom Access Token, pointing at the custom_access_token_hook Postgres function. This is manual dashboard config today; config-as-code tracking is Configure production SMTP (Cloudflare Email Service) for Supabase auth emails #157.
  2. Verify end to end on deployed Stacker: with an active retriever subscription row seeded in evermore-auth, a fresh login should carry subscribed_tools: ["retriever"] and Retriever chat should return 200 instead of 403.

Subscription enforcement was half-implemented: the subscriptions table
and the backend require_subscription gate existed, but the Supabase
custom access-token hook that projects the subscribed_tools claim into
the JWT was never built, so every module-gated route 403'd for every
user.

- Add migration public.custom_access_token_hook(event jsonb): aggregates
  module_id from public.subscriptions where status in ('active',
  'trialing') into a subscribed_tools array claim (empty array when no
  rows), merged into the event claims. Grants execute to
  supabase_auth_admin, revokes from authenticated/anon/public, and adds
  a SELECT policy scoped to supabase_auth_admin (the table RLS is
  auth.uid()-keyed and never matches the hook's own session).
- Enable [auth.hook.custom_access_token] in stacker supabase config.toml.
- Correct docs/subscriptions.md to match the implemented hook and note
  the hosted evermore-auth enablement step (tracked as #157).
Follow-ups from an architecture + security re-review of the custom
access-token hook (approach confirmed correct and kept):

- Document that subscribed_tools is evaluated only at token-issue time,
  so a canceled/past_due subscriber retains access up to the access-token
  TTL. State the invariant that jwt_expiry must stay <= 3600s, since the
  revocation window equals the TTL.
- Reconcile the evermore-auth vs stacker naming: one Supabase auth
  project, hosted as evermore-auth, whose schema is sourced from
  apps/stacker/supabase/ migrations (local project_id "stacker"). Same
  project, not two databases.
@ckrough

ckrough commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Re-evaluation of the approach (operator-requested)

Concern raised: Supabase Auth Hooks are beta, so a more mature standard may exist. Ran independent architecture (Opus) and security reviews, both fetching current Supabase docs (2026-07-13).

Verdict: keep the custom access-token hook. It is the mature, Supabase-recommended standard, and the beta premise is outdated.

  • Current docs list the Custom Access Token hook under "Available on Plan: Free, Pro" with no beta badge; the only remaining "(Beta)" is a stale dashboard-nav string in the RBAC guide. Supabase's own Custom Claims & RBAC guide prescribes exactly this design.
  • Both alternatives are strictly worse here: app_metadata sync gives no freshness gain (also baked at issue time) and adds a dual-write + service_role path; a per-request DB check adds DB credentials to every backend and a read on every request, which the documented design deliberately rejects.
  • The gate fails closed on every failure path (hook disabled, API shape-change, hook error): missing claim -> empty tuple -> 403. No drift can inject an unentitled module id. Safe direction for a paid gate.

Folded into this PR (commit afd5cd2, docs-only, gates unaffected):

  • Revocation-latency invariant: subscribed_tools is evaluated at token-issue time, so a canceled subscriber retains access up to the access-token TTL; jwt_expiry must stay <= 3600s.
  • Naming reconciliation: evermore-auth (hosted) and stacker (local project_id) are the same single Supabase auth project, not two databases.

Filed as follow-up: #291 (deploy/CI smoke check asserting the claim is projected, so a silently-disabled hosted hook is caught loudly instead of locking out every user). Detection, not a DB fallback (a fallback would convert fail-closed to fail-open). Pairs with #157.

@ckrough
ckrough merged commit fe664ed into main Jul 13, 2026
16 checks passed
@ckrough
ckrough deleted the issue/288 branch July 13, 2026 13:12
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.

Implement subscribed_tools custom access-token hook (subscription gate 403s all users)

1 participant