awslabs / awslabs/cli-agent-orchestrator

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

Open
#778 0 comments 0 reactions 0 assignees View on GitHub
enhancement
Dominant language
Python
Stars
1.3k
Forks
267
Avg merge
1d 23h
Merged PRs (30d)
70

Description

[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 (#774/#779) and visibility (#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.

Contributor guide

Open the contributing guide

Research direction

Start with the cited evidence in api/main.py:3505 and mcp_server/server.py:1156, then inspect services/profile_store.py:57-128 and api/main.py:2788-2850. Trace resource access, sibling discovery, profile and skill paths, setup, migration, and authentication before defining the shared data model. Done means the acceptance criteria pass, including cross-tenant access tests and local-to-shared migration behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
authentication, authorization, backend, databases, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.