fbb04aed92
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
123 lines
4.9 KiB
Go
123 lines
4.9 KiB
Go
package websocket
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/auth/authn"
|
|
)
|
|
|
|
// Per-user scoping for dispatched WS RPC actions.
|
|
//
|
|
// Subscriptions and HTTP routes were scoped when opt-in auth landed, but
|
|
// dispatched actions were left to authorize themselves at the service layer.
|
|
// That is per-method opt-in across ~74 actions in four packages, and it was
|
|
// missed repeatedly: session stop/delete/rename/launch, agent.cancel,
|
|
// permission.respond and agent.stdin all reached another user's session with
|
|
// nothing but a guessed ID. agent.stdin writes arbitrary bytes into the agent
|
|
// process, so the gap was equivalent to running commands in someone else's
|
|
// worktree.
|
|
//
|
|
// This is the structural backstop. Rather than trusting every present and
|
|
// future handler to remember, any action naming a task or session in its
|
|
// payload is checked here, before dispatch. Handlers still scope themselves
|
|
// (defense in depth, and they cover non-WS callers) — this makes a *newly
|
|
// added* action safe by default instead of only when its author remembers.
|
|
|
|
// scopedActionRefs are the payload fields that name a per-user resource.
|
|
//
|
|
// All four names must stay in sync with what handlers actually send. The
|
|
// user-shell actions key off task_environment_id and treat task_id as
|
|
// optional, so checking only task_id let `user_shell.stop` tear down another
|
|
// user's terminal by naming their environment with task_id empty. An action
|
|
// that invents a fifth name for the same kind of resource silently opts out
|
|
// of this backstop — see AGENTS.md.
|
|
//
|
|
// ID is the same escape hatch found a second time: task.state and task.move
|
|
// name their task `id`, so a task_id-only backstop parsed no refs and let one
|
|
// user mutate any other user's task. It is read only for top-level task.<verb>
|
|
// actions — see isTopLevelTaskAction.
|
|
type scopedActionRefs struct {
|
|
TaskID string `json:"task_id"`
|
|
SessionID string `json:"session_id"`
|
|
TaskEnvironmentID string `json:"task_environment_id"`
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// authorizeAction reports why a dispatched action must be refused, or nil when
|
|
// the caller may proceed.
|
|
//
|
|
// No-op for connections without a real identity: identity-less internal
|
|
// clients and the synthetic identity injected while auth is disabled keep the
|
|
// pre-auth see-everything behavior, exactly as callerScope does server-side.
|
|
func (c *Client) authorizeAction(ctx context.Context, action string, payload json.RawMessage) error {
|
|
if c.identity.UserID == "" || c.identity.Synthetic {
|
|
return nil
|
|
}
|
|
refs, ok := parseScopedActionRefs(payload)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
taskID := refs.TaskID
|
|
if taskID == "" && isTopLevelTaskAction(action) {
|
|
taskID = refs.ID
|
|
}
|
|
policy := c.hub.authPolicy
|
|
if refs.SessionID != "" && policy.Subscriptions.Session != nil {
|
|
if err := policy.Subscriptions.Session(ctx, refs.SessionID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if taskID != "" && policy.Subscriptions.Task != nil {
|
|
if err := policy.Subscriptions.Task(ctx, taskID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if refs.TaskEnvironmentID != "" && policy.ActionEnvironment != nil {
|
|
if err := policy.ActionEnvironment(ctx, refs.TaskEnvironmentID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseScopedActionRefs pulls the scoping IDs out of a payload. ok=false when
|
|
// the payload carries neither (or is not a JSON object at all), which means
|
|
// there is nothing to authorize.
|
|
func parseScopedActionRefs(payload json.RawMessage) (scopedActionRefs, bool) {
|
|
if len(payload) == 0 {
|
|
return scopedActionRefs{}, false
|
|
}
|
|
var refs scopedActionRefs
|
|
if err := json.Unmarshal(payload, &refs); err != nil {
|
|
// Non-object payloads (arrays, bare values) name no resource.
|
|
return scopedActionRefs{}, false
|
|
}
|
|
return refs, refs.TaskID != "" || refs.SessionID != "" || refs.TaskEnvironmentID != "" || refs.ID != ""
|
|
}
|
|
|
|
// isTopLevelTaskAction reports whether an action's payload `id` names a task.
|
|
//
|
|
// True for "task." plus exactly one more segment — task.get, task.update,
|
|
// task.delete, task.move, task.state, task.archive, and any future sibling.
|
|
// Deliberately a rule about namespace depth rather than a list of action names:
|
|
// a list has the same "someone forgot to add it" failure mode this backstop
|
|
// exists to remove.
|
|
//
|
|
// Deeper namespaces are excluded because their `id` names something else:
|
|
// task.plan.revision.get carries a revision ID and task.review.finding.update a
|
|
// finding ID, so checking either as a task ID would deny legitimate calls.
|
|
// Those actions carry task_id when they are task-scoped.
|
|
func isTopLevelTaskAction(action string) bool {
|
|
const prefix = "task."
|
|
rest, found := strings.CutPrefix(action, prefix)
|
|
return found && !strings.Contains(rest, ".")
|
|
}
|
|
|
|
// identityIsScoped reports whether this client's identity participates in
|
|
// per-user scoping. Exposed for tests and for symmetry with callerScope.
|
|
func identityIsScoped(identity authn.Identity) bool {
|
|
return identity.UserID != "" && !identity.Synthetic
|
|
}
|