Files
kandev/docs/plans/multi-tenancy/plan.md
T
João Salavisa 481359ddb2 feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:40:43 +00:00

11 KiB

spec, created, status
spec created status
docs/specs/multi-tenancy/spec.md 2026-08-22 draft

Multi-Tenancy (Organizations) Plan

Scope

Implement ../../specs/multi-tenancy/spec.md: an organization tier above users, gated by the restart-required features.multiTenancy toggle, with row-level scoping, two-tier instance/org configuration, per-org filesystem roots and agent credentials, tenant-pinned executors, and per-org background work.

This plan runs after team access — the scope registry, org roles, and workspace visibility that #2824 actually asks for. That work needs no org and ships first on today's auth model. Organizations are the boundary above it. The coupling is narrow: org visibility must mean the workspace's own org, org scopes are held in one org only, and cross-org membership is refused — one check in the add path plus a migration that drops cross-org rows, both owned by this plan in task 04.

Self-hosted deployments are the near-term target. The design is built to hold for untrusted co-tenants because a hosted deployment is the eventual goal, but every wave is validated on a self-hosted shape first.

Current State

Nothing is implemented, and this plan assumes team access has landed: an internal/authz scope registry with fixed org and workspace roles, workspaces.visibility, workspace_members, and a workspaceVisibleTo that already resolves owner / explicit row / org-visible. If tenancy were somehow built first, every org check below still applies — it would just be bounding a narrower predicate.

The existing base is the opt-in auth feature (docs/specs/auth/spec.md, internal/auth/): users with admin/member, sessions and PATs, authn.Identity in the request context, and per-user workspace scoping through workspaces.owner_id enforced by the authorize* helpers in internal/task/service/service_access.go, the WS gateway backstop in internal/gateway/websocket/dispatch_scope.go, and internal/mcp/scope.

Three properties of that base define the shape of this work:

  1. An absent identity currently means "internal caller, unscoped." Pollers, office schedulers, the orchestrator, and automation runners rely on it. That rule is a cross-tenant read the moment a second org exists, so inverting it — and migrating every background caller to an explicit per-org system identity — is the critical path, not a cleanup task.
  2. Configuration is instance-global today, and a shared agent profile means a shared provider credential. The two-tier split is what makes org-level credential ownership possible at all, and org.config.manage is the scope that gates the org tier.
  3. The filesystem is one flat tree under <home>, and agent CLIs authenticate as the backend's OS user. Row scoping does nothing about either; the isolation waves do.

Architecture

  • The tenant is derived from the identity, never from the request. authn.Identity gains OrgID; no route, payload, header, or WS frame may name an org. This is the same lesson as internal/mcp/scope resolving the owner from the AgentExecution rather than the agent-supplied payload.
  • One tenancy registry, no parallel maps. internal/tenancy/registry.go classifies every table as instance, tenant-root, or descendant (with its FK path to workspaces.org_id), and records the reason for each denormalized org_id. A completeness test enumerates the live schema and fails on an unclassified table. This mirrors internal/runtimeflags/registry.go.
  • Scope at the service layer, backstop at the transport. Org checks extend the existing authorize* helpers rather than adding a second mechanism. The WS dispatch backstop and the MCP scoper gain org resolution; neither replaces the service-layer check.
  • Denormalize org_id only where a query would otherwise leak. A descendant table gets its own column when at least one production query reaches it without joining its root. The registry records which query.
  • Instance templates are shape, org rows are substance. org_id = '' is a template; an org row shadows one by template_id. Every credential-bearing field is org-only, enforced at the API boundary and by a pinning test.
  • Filesystem tenancy is additive for upgrades. The default org's storage_root stays '', meaning the legacy flat tree, permanently. Only new orgs get <home>/orgs/<org_id>/. No mass file move ships.
  • Standalone executors fail closed. local_pc shares the backend's OS user, so HOME redirection is a convention, not a boundary. Multi-org standalone requires a per-org OS user or an explicit operator escape hatch.
  • Every wave lands behind the flag, off. The feature is mergeable at any point; features.multiTenancy only becomes settable once wave 4 completes.

Backend Touch Points

  • New packages: internal/tenancy/ (registry, classification test, org model, storage-root resolution), internal/org/ (service, store, HTTP controller, lifecycle).
  • internal/auth/: authn.Identity.OrgID / .Instance, SystemIdentity, httpmw org resolution and suspended-org fail-close, invite org binding, users.org_id, per-org email uniqueness.
  • internal/task/service/service_access.go: org checks in every authorize* helper; identity-free denial under the flag.
  • internal/gateway/websocket/: BroadcastToOrg, subscription org checks, //ws:global allowlist pinning test, port-proxy capability org binding.
  • internal/mcp/scope/: resolve stream → task → workspace → org; deny on unresolvable, as it already does for owner.
  • internal/orchestrator/, internal/office/, internal/jira/, internal/linear/, internal/github/, internal/gitlab/, internal/azuredevops/, internal/sentry/, internal/automation/: per-org cycle loops carrying SystemIdentity.
  • internal/agent/settings/store/, internal/agent/executor/, internal/task/repository/sqlite/: org_id + template_id columns and effective-view queries for the eight config kinds.
  • internal/worktree/, internal/repoclone/, internal/task/service/attachment_service.go, internal/system/ storage maintenance: org storage-root resolution.
  • internal/agent/runtime/lifecycle/, internal/agentctl/: per-org HOME/XDG_* for every ACP subprocess and agentctl child; Docker org labels, naming, volumes, and non-adoption on recovery; SSH executor org ownership; standalone fail-closed check placed before any process start.
  • internal/githubauth/ broker lease, automation webhook secret, office channel HMAC, plugin webhook secret: org binding and cross-org redemption refusal.
  • internal/runtimeflags/registry.go, profiles.yaml: features.multiTenancy and features.multiTenancyTrustedStandalone, both false in every profile.
  • cmd/kandev/e2e_reset.go: org-aware reset.

Frontend Touch Points

  • Boot payload org key and store hydration (apps/web/src/boot-payload.ts).
  • Operator surfaces under Settings > System: Organizations list/create/suspend/ delete (type-to-confirm on slug), per-org OS user, and instance Templates for the eight config kinds.
  • Org admin surface: Settings > Organization (rename, members, invites already exist and become org-bound).
  • Existing executor / profile / environment / agent / editor / prompt / notification-provider settings render source: instance rows as read-only with a "Use in this organization" action that creates the shadowing org row.
  • Organization-unavailable route for org_suspended, distinct from /login so a suspended user is not told their credentials are wrong.
  • All new copy through t() / <Trans> in five languages (pnpm run i18n:zh-hant for the Traditional Chinese pair), no em dashes.

Task Waves

Wave Tasks Theme
1 01, 02 Registry, flag, org table, identity carries the org
2 03, 04, 05, 06 Scoping: background work, service layer, transports, self-authenticating credentials
3 07, 08 Two-tier instance/org configuration
4 09, 10, 11, 15 Runtime isolation: filesystem, credentials, executors, secret keys
5 12, 13, 14 Org lifecycle, frontend, E2E and docs

Task 03 is the critical path and the highest-risk item: it changes the meaning of an identity-free context across the whole backend. It should land before any wave-2 sibling and be reviewed as a security change.

Tasks

Risks

  • Inverting the identity-free rule (task 03) can silently break background work. A poller that loses its grant fails quietly. Mitigation: the denial path logs at warn with the caller, and task 03 ships a test that runs each background entry point with an identity-free context and asserts denial — the mutation-test standard, not an observed-passing suite.
  • Missed leak surfaces. The registry completeness test covers tables, not queries. Task 04 adds a second net: a repository-level test that runs each list/get method as a foreign org and asserts an empty result or a sentinel.
  • local_pc is not a real boundary. Accepted and specced as fail-closed rather than papered over. The escape hatch is explicit, operator-only, and logged.
  • Upgrade path. The default org keeps the legacy flat tree forever, so no mass file move ships. The migration aborts the boot rather than leaving an unassignable row reachable by every tenant; persistence.Provide's pre-migration backup is the recovery path.
  • Scope creep in the other direction. Team access already decides who sees what inside an org, via visibility and scopes. This plan must not add a second visibility rule keyed on the org; it only bounds the existing one. A request that starts "when multi-tenancy is on, org admins should also see…" is a change to roles-and-scopes.md, not to this plan.
  • Sequencing pressure from cloud. If a hosted deployment moves up, the two items to pull forward are org suspension (already specced as the reversible billing lever, task 12) and per-org export, which is currently out of scope. Neither changes the isolation design.