Files
pad/internal
xarmian 69262c3b53 feat(server): periodically revalidate SSE subscriber membership (TASK-670) (#194)
* feat(server): periodically revalidate SSE subscriber membership (TASK-670)

handleSSE checked workspace access only at connection time. A removed
member kept receiving live events until they manually disconnected —
or, more commonly, indefinitely, because browser EventSource auto-
reconnects and the replay buffer filled any gaps. An owner who revoked
access had no way to stop the leak without restarting the server.

- New 60s membership revalidation ticker inside the SSE select loop.
- Store.sseSubscriberStillHasAccess mirrors RequireWorkspaceAccess's
  access matrix: fresh install bypass, admin role, direct membership,
  guest grants, legacy workspace-scoped API token. DB errors fail
  OPEN (keep connection) so a transient blip doesn't bounce every
  open tab; membership-absent fails CLOSED.
- On revocation we send the client a well-known {type:"unauthorized"}
  event with a human-readable reason BEFORE closing the stream, so
  frontend EventSource handlers can route to login / dismiss the
  workspace instead of tight-looping to reconnect.
- sseMembershipRevalInterval is a package-level var so tests can
  shrink it; pinned to the 30-300s reasonable range.
- Unit test exercises every branch: admin, active member, outsider,
  removed member, guest-grant (skipped when default collections aren't
  seeded), unauthenticated, legacy token scoped to same workspace, and
  legacy token scoped to a different workspace.

Parent: PLAN-643 (OSS Security Hardening).

* fix(web): handle server-emitted 'unauthorized' SSE event in client (TASK-670)

Addresses Codex P2 on PR #194: the server emits `{type:"unauthorized"}`
before closing a revoked stream, but the Svelte SSE service only listened
for "connected", "sync_required", and item events. Without a handler,
the default `EventSource.onerror` would auto-reconnect indefinitely on
the next /api/v1/events request — exactly the tight-loop the server
event was meant to prevent.

- New 'unauthorized' SSEStatus so surrounding UI can react (e.g.
  redirect to workspace list or show a "revoked" toast).
- Dedicated listener: on unauthorized, set status to 'unauthorized',
  close the EventSource explicitly (this prevents browser auto-
  reconnect), and null out currentWorkspace so a later connect()
  doesn't treat the closed connection as "already connected".

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): recompute SSE visibility on each revalidation tick (TASK-670)

Addresses Codex P1 on PR #194: previous revision only rebuilt the
filter maps at connect time, so a user whose scope was NARROWED
mid-stream (role downgraded to viewer, collection access tightened
to "specific", item grants revoked) kept receiving events from
collections they no longer had access to. Revocation-of-membership
was caught, but scope-tightening was not.

- Extract the filter-map computation into a new sseVisibility struct
  + (*Server).computeSSEVisibility method. Same logic as before,
  just reentrant so it can be re-run on a live connection.
- Store the snapshot in a local `vis` variable captured by the
  sseEventVisible closure (reads the CURRENT snapshot, so the next
  event dispatched after a tick sees the new permissions).
- On every revalidation tick where the subscriber still has access,
  call computeSSEVisibility again and reassign `vis`. The cost is
  one GetCollection + one GuestVisibleResources + friends per tick
  per connection — acceptable at the 60s cadence.
- New TestComputeSSEVisibility_ReflectsCurrentGrants verifies that
  a second call after membership revocation returns a different
  snapshot (isGuest flip), pinning the regression.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): jitter first SSE revalidation tick to avoid stampedes (TASK-670)

Addresses Codex P2 on PR #194: the earlier comment promised jitter but
the implementation wired a plain time.NewTicker(revalInterval). Every
stream then revalidated on a cadence tied to its connect time, which
synchronizes whenever a wave of clients connects close together (post-
deploy reconnect storm, login wave, cron-driven dashboard refresh).
The resulting periodic :00/:60 DB load spike is the exact anti-pattern
the comment warned about.

- Swap the Ticker for a Timer. First fire is delayed by a random
  uniform [0, revalInterval) window using math/rand so connect-time
  coincidence doesn't translate to revalidation-time coincidence.
- After the first fire, Timer.Reset(revalInterval) re-arms at the
  regular cadence — the jitter from connect-time is persistent for
  the lifetime of the connection, no need to re-jitter every tick.
- math/rand is fine here: this is load-spreading, not a security
  primitive, so a deterministic-at-boot PRNG is acceptable.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): re-fetch user during SSE revalidation to catch admin demotion (TASK-670)

Addresses Codex P1 on PR #194: sseSubscriberStillHasAccess early-
returned on currentUser(r).Role == "admin", but currentUser(r) is the
user snapshot cached in request context at SSE connect time. An admin
demoted mid-stream via /api/v1/admin/users/{userID} would keep the
admin short-circuit forever — the exact "admin forever" bug the
revalidation loop was meant to close.

- Re-fetch the user via s.store.GetUser(cachedUser.ID) at the start of
  each revalidation pass so role changes, disabled flags, and account
  deletions take effect on the next tick.
- User deleted → revoke.
- User disabled (IsDisabled) → revoke. Previously a disabled admin's
  stream also leaked.
- All downstream checks (admin short-circuit, membership lookup, grant
  check) use the fresh copy.

Tests:
- TestSSESubscriberStillHasAccess_AdminDemotion: bootstrap admin, hand
  it to the request context, then demote to "member" in the DB and
  verify access flips to false. Without the fresh fetch, this test
  passes even though the real system leaks — pins the regression.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): use fresh user for SSE visibility computation too (TASK-670)

Addresses Codex P1 on PR #194 (companion to the previous commit):
computeSSEVisibility called visibleCollectionIDs(r, ...), which reads
currentUser(r).Role — the cached snapshot placed in request context
when the SSE connection opened. A global admin demoted to "member"
mid-stream while keeping workspace membership would keep the admin
short-circuit forever — visibleCollectionIDs returned nil (all access)
based on the stale Role="admin", so events from collections outside
the user's new collection_access="specific" scope would keep flowing.

- computeSSEVisibility now re-fetches the user via s.store.GetUser
  before computing visibility. Transient DB errors fall back to the
  cached snapshot so a blip doesn't accidentally widen visibility.
- The admin short-circuit (visibleIDs nil) now comes from the fresh
  user.Role, so demotion immediately trips the "no, actually filter"
  path on the next revalidation tick.

Tests:
- TestComputeSSEVisibility_DemotedAdminGetsFilter: set up a global
  admin who is a workspace member with collection_access="specific"
  and NO granted collections. Before demotion the admin gets nil
  (unrestricted). Demote to "member" → the snapshot must flip to a
  non-nil visibleSlugSet (system collections only). The cached-role
  bug would keep returning nil here.

Parent: PLAN-643 (OSS Security Hardening).
2026-04-22 13:30:20 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00