Skip to content

Scope REST and websocket events to the caller's environments - #111

Merged
popen2 merged 4 commits into
mainfrom
claude/platz-websocket-scalability-8o33vx
Jun 13, 2026
Merged

popen2 merged 4 commits into
mainfrom
claude/platz-websocket-scalability-8o33vx

Conversation

@popen2

@popen2 popen2 commented Jun 13, 2026

Copy link
Copy Markdown
Member

Phase 1 (security) of the websocket scalability work. The websocket
previously broadcast every database change to every connected client with
no authentication, and the REST list/detail endpoints ignored the caller's
identity, so any authenticated user could read or be notified about objects
in environments they had no permission for.

Introduce a single AccessScope authorization layer (db/src/access.rs),
resolved once per request / per websocket connection:

  • AccessScope::All for site admins and service identities (bots,
    in-cluster deployments); AccessScope::Envs(..) for regular users,
    limited to the environments they hold any permission in.

REST: every environment-scoped model (Deployment, DeploymentTask,
DeploymentResource, Env, Secret, EnvUserPermission, DeploymentPermission)
now applies the scope as a SQL filter inside the single paginated query
(no fetch-then-filter), and exposes find_scoped() for detail endpoints.
Out-of-scope objects return the same 404 as missing ones, so existence is
not leaked through status codes.

Websocket: the /api/v2/ws endpoint now requires authentication. Since
browsers cannot set an Authorization header on a WebSocket, the access
token is carried via the Sec-WebSocket-Protocol subprotocol. A
per-connection task filters the event firehose down to the events the
identity is allowed to see, resolving each event's environment on demand
and preserving event ordering.

Known limitation (to be addressed in a later phase): because events are
not enriched with their environment, DELETE events whose row is already
gone cannot be resolved and are withheld from restricted users (fail
closed).

claude added 4 commits June 13, 2026 18:05
Phase 1 (security) of the websocket scalability work. The websocket
previously broadcast every database change to every connected client with
no authentication, and the REST list/detail endpoints ignored the caller's
identity, so any authenticated user could read or be notified about objects
in environments they had no permission for.

Introduce a single AccessScope authorization layer (db/src/access.rs),
resolved once per request / per websocket connection:

  - AccessScope::All for site admins and service identities (bots,
    in-cluster deployments); AccessScope::Envs(..) for regular users,
    limited to the environments they hold any permission in.

REST: every environment-scoped model (Deployment, DeploymentTask,
DeploymentResource, Env, Secret, EnvUserPermission, DeploymentPermission)
now applies the scope as a SQL filter inside the single paginated query
(no fetch-then-filter), and exposes find_scoped() for detail endpoints.
Out-of-scope objects return the same 404 as missing ones, so existence is
not leaked through status codes.

Websocket: the /api/v2/ws endpoint now requires authentication. Since
browsers cannot set an Authorization header on a WebSocket, the access
token is carried via the Sec-WebSocket-Protocol subprotocol. A
per-connection task filters the event firehose down to the events the
identity is allowed to see, resolving each event's environment on demand
and preserving event ordering.

Known limitation (to be addressed in a later phase): because events are
not enriched with their environment, DELETE events whose row is already
gone cannot be resolved and are withheld from restricted users (fail
closed).
Resolve the changed row's environment inside the notification trigger and
include it in the event payload, instead of having the API resolve it per
event. A new migration replaces notify_specific_trigger_name() so each
NOTIFY payload carries "env_id", resolved through the cluster:

  deployments          -> k8s_clusters.env_id (via cluster_id)
  deployment_tasks     -> k8s_clusters.env_id (via cluster_id)
  deployment_resources -> deployments -> k8s_clusters.env_id

Global tables (helm_tag_formats) carry a null env_id. Because the trigger
uses OLD on DELETE, the environment is resolved even for deletions — this
removes the previous fail-closed limitation where restricted users missed
DELETE events for rows they were entitled to see.

DbEvent gains an env_id field, so AccessScope::can_receive_event is now a
synchronous in-memory check with no per-event database query. The
websocket handler filters events inline again, dropping the per-connection
forwarding task and channel that the async resolution previously required.

Verified the trigger against a live PostgreSQL: INSERT/UPDATE/DELETE on
env-scoped tables emit the correct env_id (including DELETE), detached
clusters and global tables emit null, and the down migration reverts the
payload shape.
Turn the websocket from a per-identity firehose into a subscription feed.
The connection now starts with no subscriptions; the client sends
subscribe/unsubscribe messages over the socket for the (collection,
environment) pairs the current view needs:

  { "type": "subscribe",   "table": "deployments", "env_id": "<uuid>" }
  { "type": "unsubscribe", "table": "deployments", "env_id": "<uuid>" }

Global (non-environment-scoped) collections subscribe without an env_id.
The actor tracks the subscription set and forwards an event only when it
is both permitted (AccessScope) and matches an active subscription, so a
user viewing one environment no longer receives changes for the others.

Also add an env_id filter to the deployment-resources list endpoint
(mirroring deployments and deployment-tasks) so a view can load just the
resources of the environment it shows, enabling per-environment lazy
loading on the frontend.
@popen2
popen2 merged commit 3f3470c into main Jun 13, 2026
6 checks passed
@popen2
popen2 deleted the claude/platz-websocket-scalability-8o33vx branch June 13, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants