Skip to content

[Fix] Make API authorization default-deny with declared route policies - #133

Merged
mrubens merged 2 commits into
developfrom
api-default-deny-authorization
Jul 11, 2026
Merged

[Fix] Make API authorization default-deny with declared route policies#133
mrubens merged 2 commits into
developfrom
api-default-deny-authorization

Conversation

@mrubens

@mrubens mrubens commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Finding 13 of the pre-release architecture audit (P1). Previously, tokenAuthMiddleware attempted to attach an auth context but always continued to the handler — including after an invalid credential — so every handler was individually responsible for authorization, and a new route added without its own checks was silently exposed.

This PR makes authorization default-deny and central:

  • apps/api/src/route-policies.ts — a declarative manifest where every registered route is covered by exactly one policy rule (ordered, first match wins).
  • apps/api/src/middleware/routePolicyMiddleware.ts — a central gate registered after token auth and before all routes. A request whose path matches no declared rule is rejected with 404 before any handler can run; a request that does not satisfy its route's declared policy is rejected with 401/403 before the handler runs.
  • apps/api/src/__tests__/route-policy-inventory.test.ts — enumerates every route registered on the Hono app (including wildcard middleware mounts) and fails when any route is not covered by a policy rule, when a rule is dead/shadowed, or when the infrastructure-middleware exemption list contains anything but wildcard middleware. Future routes cannot ship unclassified: the test fails in CI and the route is unreachable at runtime until classified.
  • Rate limits — central per-client (IP) fixed-window limits backed by Redis, failing open on Redis errors/slowness so a cache outage cannot take down webhook ingestion:
    • all eight public webhook entry points: 1200 req/min per client (generous ceiling; blunts unauthenticated flooding without touching legitimate provider bursts)
    • POST /api/webhooks/teams/auth/resume: 30 req/min per client (single-use state token in the body is a brute-force target)

Route inventory by policy class

public — no credentials required (token auth still attaches a context when present so health responses can include authenticated diagnostics):

  • GET / (LB deep health check)
  • GET /health/api, GET /health/liveness, GET /health/controller
  • GET /.well-known/openid-configuration, GET /api/oidc/jwks (sandbox OIDC discovery; these also keep their existing token-auth bypass)

webhook — signature/secret-authenticated inside the handler (HMAC for GitHub/GitLab/Gitea/Slack/Linear, shared secret for ADO/Telegram, Bot Framework JWT for Teams):

  • POST /api/webhooks/github, /gitlab, /gitea, /ado, /slack, /linear, /teams, /telegram
  • POST /api/webhooks/teams/auth/resume (see judgment calls)

task-token — task run tokens only; user tokens rejected 403 centrally:

  • POST /api/artifacts, /api/artifacts/plan, /api/artifacts/visual-proof, /api/artifacts/:id/upload_complete; GET /api/artifacts/:id/url
  • GET /api/tasks/:taskId/artifacts, GET /api/tasks/:taskId/artifacts/:path

authenticated — user auth token or task run token required; finer-grained token-type and run-scoping checks remain in handlers/procedures:

  • /api/mcp/* — slack/tasks/environments MCP routes (via mcpAuthMiddleware) plus all integration MCP endpoints (asana, grafana, linear, snowflake, vercel, and the generic integration proxies); most integration proxies additionally require run tokens in-handler
  • /api/mcp-routing/* — roomote/linear/github router-facing MCP
  • GET /api/task-runs/:id/logs — run tokens scoped to their own run in the handler; user tokens may stream any run
  • /trpc/* — every tRPC procedure requires auth; userOnlyProcedure/runScoped per-procedure checks unchanged

admin/admin mount, HTTP basic auth (registered outside development, unchanged)

user — defined in the policy vocabulary, but no current HTTP mount is exclusively user-authenticated at the boundary (user-only enforcement lives in tRPC's userOnlyProcedure). Declared so future user-only routes have a class to land in.

Judgment calls

  • POST /api/webhooks/teams/auth/resume is not signature-verified; it authenticates via a single-use state token in the body (called server-to-server by the web app after account linking). Classified webhook with a strict 30/min rate limit rather than inventing a one-off class.
  • authenticated class: the audit brief named user-authenticated and task-token classes, but several surfaces (tRPC, MCP, task-run logs) legitimately accept both token types with per-endpoint scoping. Those are classified authenticated; tightening any of them to a single token type would break existing worker/web flows.
  • /admin: basic-auth middleware exists but no handler is mounted there today; kept classified so the mount stays covered if a dashboard returns.
  • Rejection bodies changed for unauthenticated/wrong-token requests (e.g. { "error": "authentication_required" } instead of handler-specific messages). Statuses are unchanged (401/403), which is what clients key on.

Preserved invariants

Rate-limiting follow-up

Central limits now cover the public webhook entry points and the Teams auth resume endpoint — the only auth-adjacent unauthenticated endpoints on this API surface. Auth/setup/OAuth initiation endpoints live in apps/web (Next.js routes / tRPC mutations), not in apps/api, so rate-limiting those is an explicit follow-up in the web app.

Testing

  • pnpm --filter @roomote/api exec vitest run (via dotenvx .env.test): 121 files / 817 tests pass, including the new inventory and enforcement suites
  • pnpm lint:fast and pnpm check-types:fast pass
  • pnpm knip reports zero findings for apps/api; the repo-level knip run fails identically on clean origin/develop in my worktree environment (skipped pnpm build scripts), i.e. pre-existing/environmental, not introduced here

@roomote-roomote

roomote-roomote Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Re-reviewed the new commit addressing the earlier feedback. All 3 prior findings are resolved and no new issues found. See task

The follow-up commit cleanly addresses every earlier point. The Teams auth-resume rate limit now keys on the state token so concurrent legitimate account links no longer share one bucket; the counter increment and TTL are now a single atomic Lua call; and the MCP surfaces emit a JSON-RPC error envelope matching handlers/mcp/proxy-utils.ts (I confirmed the c.req.json() body re-read is safe via Hono's body cache, and the -32001 code/message match the handler).

  • apps/api/src/route-policies.ts:144-152 / apps/api/src/middleware/routePolicyMiddleware.ts:131-152 (medium) — Resolved. The resume limit is now keyed on the SHA-256 of the body state token (10/min per token), so concurrent legitimate users arriving from the web app's shared egress no longer collapse into one bucket; a documented high global client-keyed ceiling (300/min) remains as a volumetric backstop.
  • apps/api/src/middleware/routePolicyMiddleware.ts:24-30,202-210 (low) — Resolved. The non-atomic INCR + conditional EXPIRE is replaced with a single Lua script (INCR then EXPIRE only when the counter is 1), so a partial failure can no longer leave an orphaned counter without a TTL.
  • apps/api/src/middleware/routePolicyMiddleware.ts:87-113 / apps/api/src/route-policies.ts:79,208,217 (low, note) — Resolved. /api/mcp/* and /api/mcp-routing/* rules carry errorFormat: 'json-rpc', and central rejections now emit the JSON-RPC error envelope (code -32001 / message matching proxy-utils.ts) instead of the plain { error } body.

roomote added 2 commits July 10, 2026 22:31
Every registered API route must now be covered by exactly one declared
route policy (public, webhook, user, task-token, authenticated, admin).
A central gate rejects requests before any handler runs when the path is
not covered by a policy (default-deny) or when the request does not
satisfy the declared policy. A route inventory test enumerates all
registered routes and fails when any route is unclassified, so future
routes cannot ship without a policy. Webhook entry points and the Teams
auth resume endpoint also get central per-client rate limits that fail
open on Redis errors.
…jections

- Key the Teams auth resume rate limit on the SHA-256 of the body state
  token so concurrent legitimate users arriving from the web app's
  shared egress never collide; keep a raised, documented client-keyed
  global ceiling (300/min) as a volumetric backstop.
- Replace the non-atomic INCR + conditional EXPIRE with a single Lua
  script so a partial failure can never leave an orphaned counter
  without a TTL.
- Emit JSON-RPC error envelopes (matching handlers/mcp/proxy-utils.ts)
  for central rejections on the /api/mcp and /api/mcp-routing surfaces
  so Streamable HTTP clients see a consistent body shape.
@mrubens
mrubens force-pushed the api-default-deny-authorization branch from e2c2b53 to 3eb1473 Compare July 11, 2026 02:32
@mrubens

mrubens commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three review findings in 3eb1473, rebased onto latest develop (56172b1).

1. MEDIUM — resume rate limit keyed on shared client bucket. The /api/webhooks/teams/auth/resume limit is now keyed on the SHA-256 of the state token from the JSON body (keySource: 'state-token'), so concurrent legitimate users arriving from the web app's shared egress each get their own bucket and can no longer starve each other; hammering any single token is throttled at 10/min. Hono caches the parsed body, so the handler's own c.req.json() still works (covered by a test that drives 10 admitted requests plus the 429 through the real handler). Requests with a missing/malformed state share a bounded fallback bucket. I kept a second, client-keyed limit as a global volumetric ceiling, raised from 30/min to 300/min and documented in the rule comment: the web app sends no client-identifying headers, so a client-keyed limit there is inherently a global ceiling, and 300/min of account-link completions is far above plausible legitimate volume while still bounding unauthenticated flooding. Rules now support a list of limits, each with its own key source.

2. LOW — non-atomic INCR + conditional EXPIRE. Replaced with a single Lua script (INCR, then EXPIRE when the count is 1) executed via redis.eval, matching the existing Lua pattern used for claiming pending Teams auth tokens. TTL arming is now atomic with counter creation, so no partial failure can leave an orphaned counter; the window-stamped key scheme is unchanged.

3. LOW — plain 401 body on MCP surfaces. Implemented rather than acknowledged: rules can declare errorFormat: 'json-rpc', set on the /api/mcp and /api/mcp-routing prefixes. Central rejections there now emit the same JSON-RPC error envelope as the in-handler rejections in handlers/mcp/proxy-utils.ts (401 -> code -32001, "Unauthorized: missing or invalid bearer token"; 403 -> code -32000), so Streamable HTTP clients that parse the body see a consistent shape. All other surfaces keep the plain { "error": ... } body. Tests assert the envelope on both MCP prefixes.

Rebase and validation. Rebased cleanly onto develop at 56172b1; develop added no new route registrations since the branch point, and the route inventory test still passes against the current surface (it would fail on any unclassified addition). Full @roomote/api suite: 122 files / 852 tests passing, including the new keying, atomicity-adjacent, and JSON-RPC assertions. pnpm lint:fast and pnpm check-types:fast pass; pnpm knip reports zero findings for apps/api and fails byte-identically on the clean develop baseline in my worktree (the known worktree knip issue), so it was skipped for the push hook only.

@mrubens
mrubens merged commit 8f9abbc into develop Jul 11, 2026
1 check passed
@mrubens
mrubens deleted the api-default-deny-authorization branch July 11, 2026 02:42
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.

2 participants