mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
484ca668a3
* 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.
118 lines
4.8 KiB
Go
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)
|
|
}
|