103878047c
* fix(auth): scope in-session agent MCP calls to the task owner The MCP tools an agent gets automatically inside its own session are relayed over the agent's WebSocket stream, which carries no credential. The backend dispatched them on the raw stream context, so the task service saw no identity — its signal for an internal caller (event bus, pollers, office schedulers) — and served the request unscoped. An agent that supplied another user's task_id or workflow_id was given their data, violating the rule that knowing an ID must not grant access. Resolve the owning user from the stream's own AgentExecution (task -> workspace -> owner) and attach that real stored identity before dispatch, so the existing authorize* checks apply exactly as they do on the PAT-authenticated /mcp endpoint. The owning task never comes from the agent-supplied payload: honoring a payload session_id would let an agent name another user's session and inherit their identity. Tool handlers are unchanged. Auth disabled stays unscoped, unclaimed pre-auth workspaces stay visible to everyone, and a lookup failure denies the dispatch rather than falling back to full access. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): never leave in-session MCP dispatch unscoped Review follow-up. Three paths still returned an identity-free context under enforced auth, and "no identity" is exactly what the task service reads as an internal caller and serves unscoped: - Unowned workspace. CreateWorkspace only stamps an owner for scoped callers, so internal callers keep producing owner_id='' rows after setup. Those streams got full cross-user access. Scope them to a sentinel user ID no account can hold: the service's "empty owner_id or my own ID" rule then limits them to the unowned rows that are public by the compatibility contract, and nothing else. - Task with no workspace. Same treatment, same reasoning. - Missing task or workspace row. A lookup that reports not-found as (nil, nil) fell through to unscoped; treat it as a resolution failure. Also deny, rather than fabricate a member identity, when the owner's account is gone or disabled. Disabling a user revokes their sessions and PATs, so a still-running agent session was the one remaining way into their workspace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
4.8 KiB
4.8 KiB
ADR-2026-07-24-opt-in-authentication: Opt-in Authentication and Per-User Workspace Scoping
Status: accepted Date: 2026-07-24 Area: backend, frontend, protocol, security
Context
Kandev shipped with no user authentication: one hardcoded default-user row,
a WS handshake that read and discarded its token, unfiltered hub broadcasts,
and a server that binds 0.0.0.0 by default. Shared-server deployments need
accounts and privacy between users; local single-user installs must not gain
a login screen or any behavioral change.
Decision
- Opt-in via the
features.authruntime feature toggle, resolved intocfg.Features.Authat startup (envKANDEV_FEATURES_AUTH> DB override > profile default). The effective mode is derived from it, not persisted separately:flag off → disabled,flag on + no admin → setup, `flag on- admin exists → enabled
. Enablement lives in the existing Feature Toggles system — there is no bespokeauth.mode` setting or Authentication page. (An earlier iteration split "reveal UI" and "enforce" into two controls; they were collapsed into the one flag.)
- admin exists → enabled
- Synthetic identity in disabled mode. The global HTTP middleware (and
WS gateway) inject
Identity{default-user, admin, Synthetic}when auth is off. Downstream code branches on identity, never on mode — internal callers (event bus, pollers, office schedulers) carry no identity and are unscoped. This keeps disabled-mode behavior byte-identical and makes every consumer identity-aware in one step. In-session agent MCP calls arrive over the agent's credential-less WebSocket stream, so they initially fell into the same unscoped bucket;internal/mcp/scopenow resolves the stream's task owner and attaches that real identity before dispatch (the owning task comes from theAgentExecution, never from the agent-supplied payload). Because "no identity" means full access, that path never returns an identity-free context under enforced auth: an unowned workspace is scoped to a sentinel user ID that reaches unowned rows only, and an unresolvable owner — deleted task/workspace row, or an account since deleted or disabled — denies the dispatch. Disabling a user revokes their sessions and PATs, so their still-running agent session must lose this surface too. - Opaque DB-backed credentials, not JWTs. Sessions (
kandev_sessionHttpOnly SameSite=Lax cookie) and personal access tokens (kandev_pat_*) are 256-bit random values stored as SHA-256 digests. Instant revocation (user disable, logout-all) outweighs stateless verification at kandev scale. The office agent HMAC-JWT remains a separate machine credential. - Setup wizard promotes
default-user. The first account reuses the pre-auth user row (preserving all settings) and claims unowned workspaces and secrets. Rows with empty owners remain visible to everyone until claimed — the compatibility contract for pre-auth data. - Scoping at the service layer. The task service filters and denies by
the ctx identity with
*NotFoundsentinels (no existence leak); admins do NOT bypass (admin is a management role, not a visibility role). Session-scoped surfaces funnel through one lifecycle chokepoint (GetOrEnsureExecution); WS subscriptions, dispatched WS RPC, and workspace-carrying broadcasts (Hub.BroadcastToWorkspace) apply the same rules. New globalhub.Broadcastcall sites require a//ws:globaljustification. - Explicit public allowlist in
auth/httpmw: readiness probe, SPA shell/static, bootstrap reads (features,app-state), credential endpoints, and self-authenticating webhooks./mcpenforces PATs in its own group middleware; office agent JWTs pass through the global layer toAgentAuthMiddleware.
Consequences
- Multi-user servers get real accounts, invites, per-user workspaces and secrets, and scoped live updates; laptop installs are untouched.
- Every future user-facing service entry point must apply identity scoping
(see
apps/backend/AGENTS.md); the allowlist is pinned by tests. - Filesystem isolation is NOT provided — worktrees share one
~/.kandevtree. Documented limitation; per-user executor sandboxing is future work. - OIDC/SSO can be added by inserting rows with a new provider into
auth_identities; no migration required. - Office workspace-scoped HTTP routes are ownership-gated via a group middleware (agent-JWT callers keep their workspace-claim scoping); office run subscriptions remain unchecked — accepted gap, noted in the spec.
- The session-access check is enforced at the lifecycle chokepoint AND at the reverse-proxy handlers (vscode/port) that resolve executions by bare lookup; message read/search and repository-script routes carry explicit scoping. These closed IDOR/read gaps found in the pre-merge security audit.