Scope REST and websocket events to the caller's environments - #111
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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).