feat: re-authorize live subscriptions; revoke on permission loss or token expiry (#1414) - #1535
Conversation
…oken expiry (#1414) Subscribe-time authorization is point-in-time: once an SSE/WebSocket/MQTT stream is open it keeps delivering even after the principal loses access (drop_user, role or permission change) or the bearer token it was opened with expires. This adds a continuous re-authorization registry that terminates such subscriptions. - server/liveSubscriptionAuth.ts: a registry of live subscriptions, each with a table/RBAC-level recheck and a terminate handler. Swept (1) immediately on the ITC user-change broadcast — serverHandlers rebuilds the user/role cache before firing listeners, so the recheck sees current permissions — and (2) on a 30s interval as a backstop and to catch token expiry, which is not event-signaled. Re-auth is table-level (re-runs the same allowRead the subscription was granted with against a freshly-fetched user); there is NO per-record evaluation. An error during recheck fails closed (revokes). Normal teardown auto-unregisters. - resources/Resource.ts: at the common authorization chokepoint (authorizeActionOnResource), register the resulting subscription for both the 'subscribe' (MQTT) and 'connect' (SSE/WebSocket) actions. Subscriptions with no user principal (internal watchers, replication, local-bypass) are skipped. - security/auth.ts: capture the bearer token's JWT exp on the authenticated user so a subscription opened with it can be revoked once it expires. Re-auth interval is overridable via HARPER_SUBSCRIPTION_REAUTH_INTERVAL_MS (tests). Test: integrationTests/security/subscription-revocation.test.ts opens an SSE collection subscription and asserts delivery STOPS after (1) drop_user (event-driven) and (2) bearer-token expiry (interval-driven), while an authorized stream keeps delivering. 2/2 pass. Closes #1414. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements continuous re-authorization for live subscriptions (SSE, WebSocket, MQTT) to ensure they are terminated if a user's permissions are revoked or if their bearer token expires. The implementation includes a new registry and background sweeper, along with comprehensive integration tests. The review feedback highlights two important issues: a critical security vulnerability where stale user data in the context could bypass authorization checks during re-evaluation, and a potential stream cleanup bug where arguments are not forwarded when overriding the subscription's end method.
|
Reviewed; no blockers found. |
…gs, format - recheck advances context.user to the freshly-fetched user before re-running allowRead, so a custom allowRead reading context.user / getCurrentUser() evaluates current state rather than the stale subscribe-time user (Gemini critical). - the wrapped subscription.end() forwards all arguments to the original end() so stream cleanup semantics are preserved (Gemini high). - prettier formatting on the new test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
This is an epic feature, love it!
# Conflicts: # resources/Resource.ts # security/auth.ts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Subscribe-time authorization is point-in-time: once an SSE/WebSocket/MQTT stream is open it keeps delivering even after the principal loses access (
drop_user, role/permission change) or the bearer token it was opened with expires (#1414). This adds continuous re-authorization that terminates such subscriptions.Re-authorization is table/RBAC-level — it re-runs the same
allowReadthe subscription was granted with, against a freshly-fetched user. There is no per-record evaluation (consistent with theallowReadgrant model and default RBAC's table-level granularity).How it works
server/liveSubscriptionAuth.ts— a registry of live subscriptions, each with arecheck()and aterminate(). Swept:serverHandlers.userHandlerrebuilds the user/role cache before firing listeners, so the recheck observes current permissions; andAn error during recheck fails closed (revokes). Normal teardown (
end()/close) auto-unregisters.resources/Resource.ts— registers the resulting subscription at the common auth chokepoint (authorizeActionOnResource) for both thesubscribe(MQTT) andconnect(SSE/WebSocket) actions. Subscriptions with no user principal (internal watchers, replication, local-bypass) are skipped.security/auth.ts— captures the bearer token's JWTexpon the authenticated user so a subscription opened with it can be revoked on expiry.The recheck interval is overridable via
HARPER_SUBSCRIPTION_REAUTH_INTERVAL_MS(used by the test).Terminate semantics
terminate()calls the subscription'send(), which removes it from the broadcast notify loop — so no further events are delivered on either transport (the security-critical outcome). The transport connection may linger idle until the client disconnects; a clean socket close is a possible refinement.Testing
integrationTests/security/subscription-revocation.test.tsopens an SSE collection subscription and asserts delivery stops after (1)drop_user(event-driven) and (2) bearer-token expiry (interval-driven), while an authorized stream keeps delivering. 2/2 pass.tscclean.Notes / review asks
exp), so token-expiry plumbing is HTTP-only; permission-loss covers all transports via the user re-fetch.allowRead— all rows leak to any collection subscriber #1419/fix: enforce row-level allowRead during subscription delivery (#1419) #1524 (per-record subscriptionallowRead), which were closed — this is the table-level revocation that is the real subscription-security need.Closes #1414.
🤖 Generated with Claude Code