Skip to content

[Feat] Tenant model and isolation boundary: make an organisation a first-class thing in CAO #778

Description

@haofeif

[Feat] Tenant model and isolation boundary: make an organisation a first-class thing in CAO
Part of #777 (CAO 3.0). This is the foundation of 3.0's data model. #774, #779 and #780 all build on it, and all four land in a single shared migration — see the build order in #777. The remote-execution bridge (#745 and #776) can be developed in parallel using local identity; its shared-use integration must consume the same tenant, owner and revocation contracts.

In plain terms

A tenant is one organisation using CAO. Woodside is a tenant. A customer running their own CAO is a tenant. On your laptop, you are a tenant of one.

The tenant is the line CAO enforces. Two people in the same tenant might deliberately share a workflow. Two different tenants must never see a trace of each other through CAO — not a name, not a count, not an error message.

What that means, and what it does not. This is enforcement inside the application: access to tenant-owned data is filtered by tenant, and a missing filter is a bug. It is not an operating-system boundary. #745 moves execution into separate runtime workloads; #784 covers stronger containment for code and organisations that cannot share a trust boundary.

So 3.0 makes two different promises, and they must not be confused:

3.0
Data model Can represent many organisations. CAO filters every query by tenant and is tested for it.
Reference deployment One centrally operated CAO service with approved execution, storage, network and credential boundaries. A one-organisation example is a starting configuration, not a restriction on the tenant model or proof of workload safety.

Workloads requiring containment beyond that baseline need an appropriate stronger runtime or separate deployment before they are admitted (#784). Cross-tenant application tests remain required in either case; they prove the application boundary, not host or kernel containment.

Stronger execution containment is addressed by #784 and must be selected from workload trust and reachable privileges, not just organisation count. Untrusted code can arrive inside one organisation too (#785). A tenant nevertheless remains an organisation, not a team. Teams and projects are groups within a tenant (#780); the SaaS foundation must not redefine tenancy to mean those groups.

Right now CAO has no idea this concept exists. The codebase says so directly, in two separate files:

"nothing here provides tenant isolation"

Why this has to come first

It's tempting to skip straight to "give everything an owner" (#774) and add organisations later. That would be expensive twice over.

Tenant and owner affect resource keys, stored files and access paths together. Design them together before migrating shared data, rather than building a per-person namespace and later discovering that one person belongs to multiple organisations. The important saving is avoiding another cross-application identity redesign, not a blanket claim that adding any column rewrites every table.

There's a second reason. Ownership without tenancy quietly answers a question wrongly. If Alice owns deploy and Bob owns deploy, are they colleagues who should probably be sharing, or two unrelated companies who must never meet? Only the tenant tells you.

What this delivers

A tenant is a real thing in the system. It has an identity, a name, and configuration attached to it.

Every tenant-owned resource record belongs to exactly one tenant. Not optional, not nullable. Distinguish those records from installation configuration, identity infrastructure and the packaged read-only catalogue rather than inventing a tenant owner for everything in the installation.

Tenant-resource access is scoped at a level that can't be forgotten. Put the query rule in the data access layer, using #774's trusted authorization context, rather than trusting 96 route handlers to remember a WHERE clause. The same boundary must cover files, search/discovery, cached results and live-event subscriptions; a SQL filter alone does not protect those. Today 80 routes use the standard scope dependency; some others check scopes inline, which still does not provide tenant filtering (#774).

A CAO tenant is independent of an identity-provider directory. Operator-approved federation configuration maps validated external identities to CAO tenants and their memberships. Entra ID and SAML are the first-class targets. A directory ID, email domain or client-supplied tenant name is not itself CAO authorization. SCIM does not define this mapping: RFC 7644 section 6 says "Multi-tenancy is OPTIONAL. The SCIM protocol does not define a scheme for multi-tenancy".

Each tenant can bring its own identity provider; different tenants need not have different providers or directories. Before login, a configured tenant/invitation hint can select the approved federation. It grants no membership and must not expose a browsable directory of private tenant data. After protocol validation, bind the stable external identity (#774), verify active membership (#779), and select only an authorized tenant. Do not assume one issuer identifies exactly one CAO organisation.

Your laptop is a tenant of one. A fixed local tenant, created automatically, never shown in the interface, requiring no sign-in. This is what keeps the local experience unchanged, and it means there is one code path rather than a local path and a shared path that drift apart.

First setup and the move from local to shared

Use operator-controlled deployment/setup mechanisms to create a tenant, configure its federation and designate its first administrator. An empty database or the first successful public login must not grant administration by itself. #779 owns subsequent invitations and last-administrator protection; this does not require a new platform-management service.

For an upgraded local installation, preserve the internal tenant and owner IDs. The operator explicitly binds the intended validated work identity to the existing local owner, so enabling sign-in does not strand workflows and memories under an unreachable account or transfer them to an arbitrary first visitor. Test both a fresh shared setup and this local-to-shared transition.

Shared operation is an explicit deployment choice. Once enabled, incomplete federation configuration or failed authentication must fail closed, never turn the caller back into the implicit local administrator. Local and shared callers then use the same authorization/data-access implementation with different, deliberately established principals.

Resource ownership includes more than database rows

Keep one resource/access inventory, consumed by #774, #779 and the shared migration:

Resource class Ownership and access
User-created workflows, flows, memory, profiles, skills and execution state Tenant and owner; private in 3.0, with #779's explicit tenant-admin intervention
Packaged or explicitly operator-published catalogue content Read-only reference content for its approved audience; private copies do not alter the original
Installation-wide settings, filesystem roots, workload templates and platform credentials Operator-controlled, not writable merely because a caller is a tenant administrator or has cao:write
Tenant configuration and memberships Bound to the tenant, with only the management operations explicitly granted by #779

Current profile writes use a shared store and the agent/skill-directory setters change installation-wide settings (services/profile_store.py:57-128, api/main.py:2788-2850). Include their read, discovery, mutation and launch-resolution paths in the inventory. Tenant administrators must not gain control of the host filesystem or another tenant's configuration through those endpoints.

Which isolation model

Worth being precise, because the industry vocabulary is not interchangeable — AWS's silo / pool / bridge describes resource sharing, Azure's models describe deployment topology, and Kubernetes' hard / soft describes trust. Using them as synonyms causes confusion.

For 3.0 the proposal is the pool model for tenant-owned resources: one database and shared tables, with each resource row carrying its tenant. Operator configuration and identity infrastructure follow the resource classification above. Isolation is enforced by our code rather than by infrastructure, which is why the access rule belongs in one enforced place.

Stronger execution guarantees are selected under #784, including separate deployments where required. They complement this application data model rather than changing its definition of a tenant.

Acceptance criteria

  • A tenant is a first-class record with its own identity and configuration.
  • Every tenant-owned resource carries a non-nullable tenant, with identity/membership/session records designed alongside ownership ([Feat] Sign in with SAML or Entra ID, and give every resource an owner #774/[Feat] Tenant administration: roles, member management, and revocable sessions #779) and visibility ([Feat] Sharing and visibility within a tenant (schema in 3.0, experience after) #780) in the same owned migration. The resource inventory explicitly classifies operator configuration and catalogue content.
  • Tenant filtering is enforced centrally, and a new query cannot accidentally omit it.
  • There is a test that proves one tenant cannot read, address, name, or enumerate another's records — including through error messages and counts.
  • A person with memberships in two tenants has independent named resources, aliases, approvals and idempotency keys in each. Uniqueness and file namespaces include tenant as well as owner, not just read-time filters.
  • Pre-login tenant hints only choose approved federation configuration. Validated identity plus active membership establishes access; test both different issuers and multiple CAO tenants using the same issuer.
  • Fresh shared setup has an operator-designated first administrator, and an arbitrary first visitor cannot claim the tenant or its data.
  • Enabling shared sign-in on an upgraded local installation preserves access for the explicitly bound owner, without changing resource ownership or granting another account access.
  • Missing/broken shared authentication configuration cannot activate no-auth local behavior.
  • Custom profiles/skills are isolated across owners and tenants through discovery, editing and launch; the built-in catalogue remains read-only, and tenant roles cannot mutate installation-wide settings.
  • On a laptop, a single local tenant is created automatically and no sign-in is required.
  • Upgrading an existing 2.5 install places all existing data in that local tenant, losing nothing.

Evidence

Claim Where
No tenant isolation exists api/main.py:3505, mcp_server/server.py:1156
No tenant/user/org column in any table 14 tables, 0 matches for tenant/owner/user columns
SCIM does not define multi-tenancy RFC 7644 §6

All repository observations verified on main at 29b235cf62ed0f9d624bc9ad9afce09ab72f8ddf.

One specific surface that must not be missed

CAO already lets one agent discover and message another. The filter today is the caller's tmux session plus a shared group name prefix — and the session filter can be lifted by passing cross_session=true, which is a supported option.

The code's own docstring anticipates the failure:

"two unrelated CAO sessions that happen to reuse the same group prefix (a naming collision, a copy-pasted template, two features that picked the same tenant/project id) would silently discover each other"

Today that's a naming accident between one person's workflows. With tenants it becomes a cross-tenant leak: a prefix collision plus a legitimate flag, and Alice's supervisor discovers Bob's workers and can message them.

So the tenant filter has to sit beneath the group logic, not beside it:

  • Sibling discovery filters by tenant before any group-prefix matching.
  • cross_session=true widens across sessions but can never cross a tenant.
  • Messaging a terminal by id checks the tenant, since it addresses a terminal directly and doesn't go through discovery at all.
  • A test proves a group-prefix collision across two tenants discovers nothing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions