Files
pad/internal/server/workspace_access_notify.go
T
xarmian 484ca668a3 feat(server): revoking access re-checks live connections immediately (#1744)
* feat(server): access kicks re-check live connections now, not on the next tick (TASK-3365 PR1)

Dave's ruling on TASK-3345 #3. A per-instance registry indexes live collab,
/events and /events/stream connections by user and workspace; a kick runs
the revalidation each already performs on its 60s tick immediately (the tick
stays as the backstop). Kicks travel the watch bus as the server-internal
KindAccessInvalidated, which watchStreamDelivers drops, and the existing
workspace_access_changed publication kicks too, so the doors that already
publish it are covered. Kicks are non-blocking sends that coalesce per
connection. PR2 wires the remaining access and credential doors.

* fix(server): access kicks ride their own transport, reach watch streams, install idempotently (TASK-3365 PR1)

Codex r1: kicks on the client watch bus took sequence ids, replay slots and
client queue space, so they could evict real notifications and open gaps.
They now ride internal/accesskick (Redis pub/sub on its own channel, or
local with one instance), and the access-changed helpers kick directly, so
nothing internal touches the client bus and the internal kind is gone.
Watch streams register under a wildcard workspace so workspace kicks reach
them. SetAccessKickTransport is idempotent and replacement stops the old
subscription. A partial revoke kicks too. Tests make the first tick
deterministic.

* fix(server): access kicks never wait on Redis on a request path (TASK-3365 PR1)

Codex r2: the shared Redis client ignores a context deadline on command I/O,
so a stalled Redis held each publish ~5s, serially per recipient, on the
request that revoked access. Local connections are now kicked at once and
remote publication goes through one bounded background worker (dropped when
full; other instances fall back to the tick); the transport uses its own
client clone with context timeouts enabled; lifecycle changes send one
workspace kick, and a complete revoke no longer kicks twice.

* fix(server): access-kick worker cancels on removal; transport owns and closes its client (TASK-3365 PR1)

Codex r3: the worker published with context.Background, so removing a
stalled transport left the worker and queue alive; it now has its own
cancellable context. The transport's cloned client is closed when the
server replaces or clears it, and the clone gets its own push-notification
processor instead of registering on the source client's.
2026-10-02 19:43:19 -04:00

118 lines
4.8 KiB
Go

package server
import (
"log/slog"
"net/http"
"github.com/PerpetualSoftware/pad/internal/watchevents"
)
// workspace_access_changed publishers (TASK-3272, PLAN-3002 U7a).
//
// Every door that gives a user access to a workspace, takes it away, or
// deletes, restores or purges the workspace itself publishes one
// notification per affected user on the user stream, AFTER its write has
// committed. The event is a refetch hint for the web tab bar: correctness
// lives on the read-filtered GET /me/workspace-tabs, so a duplicate or a
// missed event under concurrent grant writes is benign and nothing here
// locks to make it exact. The door x recipient table is checkpoint 3 on
// TASK-3272's trail.
//
// Two doors grant access and deliberately publish nothing: cloud
// autoCreateWorkspace and register-with-invitation (handlers_auth.go). Every
// caller of either acts on an account created in that same request, which
// cannot yet hold a stream to receive the event, and register can still
// delete that account after its membership add. A door that grants access to
// an EXISTING account must publish.
// publishWorkspaceAccessChanged publishes one notification per user. Actor
// and actorName describe who caused the change; a request-less caller (the
// scheduled purge) passes "system" and "".
func (s *Server) publishWorkspaceAccessChanged(workspaceID, change string, userIDs []string, actor, actorName string) {
// TASK-3365: every door that changes whether a user reaches a workspace
// comes through here, so it kicks their live connections to re-check
// NOW, bus or no bus. Lifecycle changes kick the whole workspace too, which
// reaches legacy workspace-token streams that have no user.
switch change {
case watchevents.AccessDeleted, watchevents.AccessRestored, watchevents.AccessPurged:
// One workspace kick reaches every connection on it, the users
// listed here included (codex r2: not one publish per user).
s.invalidateWorkspaceAccess(workspaceID)
default:
for _, uid := range userIDs {
s.invalidateUserAccess(uid)
}
}
if s.watchEvents == nil {
return
}
seen := make(map[string]bool, len(userIDs))
for _, uid := range userIDs {
if uid == "" || seen[uid] {
continue
}
seen[uid] = true
s.publishWatchNotification(watchevents.Notification{
WorkspaceID: workspaceID,
Kind: watchevents.KindWorkspaceAccessChanged,
AccessChange: change,
TargetUserID: uid,
Actor: actor,
ActorName: actorName,
Summary: "workspace access " + change,
})
}
}
// publishWorkspaceAccessChangedFromRequest is publishWorkspaceAccessChanged
// with the actor read off the request that made the change.
func (s *Server) publishWorkspaceAccessChangedFromRequest(r *http.Request, workspaceID, change string, userIDs ...string) {
actor, _ := actorFromRequest(r)
s.publishWorkspaceAccessChanged(workspaceID, change, userIDs, actor, actorNameFromRequest(r))
}
// userReachesWorkspace reports whether a user reaches a workspace as a
// member or through any live grant: the question a grant or member write
// asks before and after itself to decide whether access was gained or lost.
func (s *Server) userReachesWorkspace(workspaceID, userID string) (bool, error) {
m, err := s.store.GetWorkspaceMember(workspaceID, userID)
if err != nil {
return false, err
}
if m != nil {
return true, nil
}
return s.store.UserHasGrantsInWorkspace(workspaceID, userID)
}
// workspaceAccessUsers reads the users who reach a workspace, for a
// lifecycle door that must read them BEFORE its own write (a purge deletes
// the rows). A failed read is logged and yields nobody: the write it
// precedes must not fail for want of a hint.
func (s *Server) workspaceAccessUsers(workspaceID string) []string {
ids, err := s.store.ListWorkspaceAccessUserIDs(workspaceID)
if err != nil {
slog.Warn("workspace_access_changed: could not read the workspace's users; no notification will be sent",
"workspace_id", workspaceID, "error", err)
return nil
}
return ids
}
// publishLostIfUnreachable publishes "lost" for a user after a per-user loss
// write (member removal, grant revoke) when nothing is left that reaches the
// workspace: a member removed while keeping a grant, or a guest losing one of
// two grants, still reaches it. A failed read publishes anyway, because the
// event only asks the client to refetch.
func (s *Server) publishLostIfUnreachable(r *http.Request, workspaceID, userID string) {
reaches, err := s.userReachesWorkspace(workspaceID, userID)
if err == nil && reaches {
// TASK-3365: a removal or revoke that leaves the user SOME access
// still narrows it, so their connections re-check. ("Lost" below
// kicks through publishWorkspaceAccessChanged.)
s.invalidateUserAccess(userID)
return
}
s.publishWorkspaceAccessChangedFromRequest(r, workspaceID, watchevents.AccessLost, userID)
}