Skip to content

fix(authz): close stale-membership access in chat, align REST session freshness with tRPC - #438

Merged
filopedraz merged 1 commit into
mainfrom
kosuke/chat-ubqmgy
Sep 7, 2026
Merged

fix(authz): close stale-membership access in chat, align REST session freshness with tRPC#438
filopedraz merged 1 commit into
mainfrom
kosuke/chat-ubqmgy

Conversation

@filopedraz

Copy link
Copy Markdown
Member

What's Changed

Two authorization inconsistencies found while extracting the REST guards in #434, both deliberately left out of that PR because they change behavior.

1. Stale-membership access in the chat route. app/api/chat/route.ts verified that a chat session belonged to the caller, then read organizationId off that session row and used it to reach the org's file search store and RAG settings, without checking that the user was still a member. A user removed from an organization kept working access to that org's document store through any session created while they were a member. Now goes through requireOrgAccess.

2. REST accepted cached sessions while tRPC did not. createTRPCContext passes disableCookieCache: true; the REST guards used the default. A revoked session stayed valid at the REST edge for up to cookieCache.maxAge (300s) while tRPC rejected it in the same instant. Two doors, two answers, same credential. Fixed in one place, which is the point of having extracted the guards.

Type of Change

  • 🔒 security - Security fix

Testing

  • Added new tests for changes (if applicable)

49 files, 582 tests passing. New tests cover a deleted membership row returning 403 on an existing chat session, and a guard-level assertion that the uncached session path is requested so this cannot silently regress.

The latency tradeoff

Measured in-sandbox, 2000 iterations after 200 warmup, against running Redis and Postgres. storeSessionInDatabase is false and secondaryStorage is Redis, so the marginal cost is one Redis GET, not a Postgres query.

Path p50 p95 p99
Uncached (Redis GET + parse) 0.085 ms 0.132 ms 0.183 ms
Cached (HMAC verify + parse) 0.070 ms 0.099 ms 0.140 ms
Delta +0.015 ms +0.033 ms +0.043 ms

Loopback Redis is a floor; against a same-region managed instance the honest estimate is +0.5 to 1.5 ms per REST request. The REST surface is four routes: two serve files, one takes a Stripe webhook, and the fourth streams an LLM response measured in seconds. This buys back a 300-second window where the two doors disagreed about the same credential.

The chat route's new membership lookup measured p50 0.136 ms, p95 0.194 ms.

Follow-up found, not fixed here

orgOwnerProcedure in lib/trpc/init.ts authorizes off ctx.activeOrganizationId and ctx.orgRole, both read from the session row, with no orgMemberships lookup. activeOrganizationRole is written only by the session-create hook and the active-org-change hook; nothing in member-service rewrites existing sessions. So demoting an owner to member, or removing them, leaves existing sessions carrying activeOrganizationRole: 'owner' until they switch orgs or re-authenticate, and that procedure gates delete-organization and billing. disableCookieCache does not help, because the stale value is in the session row itself. The fix is one orgMemberships lookup in that middleware, same shape as orgProcedure.


🤖 Generated with Claude Code

https://claude.ai/code/session_01GyBfVKhYQGLZhkcWiHaruY

…g chat session

The chat route trusted chatSessions.organizationId — a stored value written
when the session was created — to reach the organization's file search store,
RAG settings and document proxy URLs. A user removed from the organization kept
that access for as long as the chat session row existed, while the documents and
uploads routes revoked it correctly.

Resolve the chat session, then run requireOrgAccess against its organizationId
before any org-scoped read. The guard reuses the per-request session memo, so
this costs one membership lookup and no extra session round trip.

Also pin every guard entry point, not just requireUser, to an uncached session
read, so a guard that grows its own getSession call cannot silently reopen the
gap between the REST edge and createTRPCContext.
@filopedraz
filopedraz merged commit 441613c into main Sep 7, 2026
4 checks passed
@filopedraz
filopedraz deleted the kosuke/chat-ubqmgy branch September 7, 2026 15:08
filopedraz pushed a commit that referenced this pull request Sep 7, 2026
Resolves bun.lock by regeneration; package.json auto-merged cleanly and
keeps the AI SDK v7 pins alongside husky 9, exceljs and Node 22 from #436.

Fixes a semantic conflict git could not see. #438 added
__tests__/api/chat/route.test.ts with mocks written against the v5 AI SDK
surface, while this branch migrated the route to v7:

  - @ai-sdk/google: createGoogleGenerativeAI -> createGoogle
  - ai: result.toUIMessageStreamResponse() ->
    createUIMessageStreamResponse({ stream: toUIMessageStream(...) })

Both mocks now match the surface the route actually calls. The merge was
textually clean and the suite failed at collection, which is the only
reason this was caught.

Gate verified: typecheck, lint, 744 tests across 55 files, format:check, knip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyBfVKhYQGLZhkcWiHaruY
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