Files
kandev/docs/decisions/2026-07-24-opt-in-authentication.md
T
João Salavisa 103878047c fix(auth): scope in-session agent MCP calls to the task owner (#1937)
* 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>
2026-07-25 09:56:47 +01:00

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

  1. Opt-in via the features.auth runtime feature toggle, resolved into cfg.Features.Auth at startup (env KANDEV_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 bespoke auth.mode` setting or Authentication page. (An earlier iteration split "reveal UI" and "enforce" into two controls; they were collapsed into the one flag.)
  2. 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/scope now resolves the stream's task owner and attaches that real identity before dispatch (the owning task comes from the AgentExecution, 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.
  3. Opaque DB-backed credentials, not JWTs. Sessions (kandev_session HttpOnly 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.
  4. 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.
  5. Scoping at the service layer. The task service filters and denies by the ctx identity with *NotFound sentinels (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 global hub.Broadcast call sites require a //ws:global justification.
  6. Explicit public allowlist in auth/httpmw: readiness probe, SPA shell/static, bootstrap reads (features, app-state), credential endpoints, and self-authenticating webhooks. /mcp enforces PATs in its own group middleware; office agent JWTs pass through the global layer to AgentAuthMiddleware.

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 ~/.kandev tree. 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.