Org-Tier Isolation (Multi-Tenancy)
Agent Assembly enforces a three-tier isolation hierarchy — Org / Team / Agent — so a single gateway can safely host workloads from multiple tenants. AAASM-1524 covers the Agent and Team tiers; this guide describes the Org tier added in AAASM-2008.
What the Org tier guarantees
When agents are registered with a non-empty proto.AgentId.org_id, the
gateway enforces the following invariants:
| Surface | Org-tier behaviour |
|---|---|
| Audit log | Every audit entry carries the originating agent’s org_id on Lineage. GET /api/v1/logs?org_id=X filters to a single tenant. |
| Topology | GET /api/v1/topology/overview?org_id=X returns only X’s agents. The registry maintains an org_index secondary index for O(members) lookup. |
| Credential validation | An agent registered in Org A presenting its valid token but claiming agent_id.org_id = "B" is rejected with A2AImpersonationAttempted. The registry’s credential reverse-index catches cross-org reuse before any policy evaluation. |
| Policy scope | A policy with scope: org:<id> cascades only for agents in that org. (Requires the multi-document loader from AAASM-2023 — partial today.) |
| Budget | Every Org owns an independent spend envelope on the BudgetTracker.org_budgets map. record_cost rolls each charge into the agent’s org_id and enforces org_daily_limit_usd / org_monthly_limit_usd set via policy YAML or the with_org_*_limit builders. Exhausting one Org’s envelope never affects another. |
How an agent gets an organization
An organization is bound to an operator credential, never taken from the
client. agent_id.org_id on a registration is only a claim; the gateway
decides the agent’s org from the operator API key presented with the
registration (the same aa-auth key store, AA_API_KEYS_PATH, that binds REST
callers to a tenant) and refuses a claim the key does not back.
- The operator issues an org-bound key (an
ApiKeyEntrywithorg_id: acme) and gives it to the agent’s deployment. - The SDK presents it on
Registerin thex-aa-registration-keygRPC metadata (never in the signed body):
init_assembly(
gateway_url="http://gateway:7391",
agent_id="research-bot-001",
team_id="platform",
org_id="acme", # optional claim; omit to take the key's org
registration_key=os.environ["AA_REGISTRATION_KEY"], # org-bound operator key
)
AA_REGISTRATION_KEY is also read by the native client when the argument is
omitted. The key is a bearer credential: use an https:// gateway for anything
that is not on loopback. The client marks the header sensitive, but it does not
encrypt the connection. The runtime sidecar of an org-bound agent must be started with
AA_AGENT_ORG_ID=<org> so its op-control subscription finds the agent (the
registry keys an agent by org/team/agent).
Registration outcomes
| Registration key | Claimed org_id | Result |
|---|---|---|
| none | empty | Registered org-less (the single-tenant default; unchanged) |
| none | X | Rejected PermissionDenied |
| invalid, revoked or expired | any | Rejected Unauthenticated |
bound to O | empty | Registered in O (derived; echoed in RegisterResponse.org_id) |
bound to O | O | Registered in O |
bound to O | other | Rejected PermissionDenied (spoof) |
| admin key, no org binding | X | Registered in X (operator-authorised) |
| non-admin key, no org binding | X | Rejected PermissionDenied |
| non-admin key, no org binding | empty | Registered org-less |
Only the org the registration binding validated is ever used as the agent’s org
(audit stamp, policy cascade, sensitive-data scope). A free-form
RegisterRequest.metadata entry named org_id is client text and is ignored.
An unusable header (blank, non-ASCII, or sent twice) is Unauthenticated, never
“no key”.
A later process of an already-registered agent
A second process of an agent that is already registered (same did:key, team and
organization) re-binds to the existing record, and receives its credential and
team_id, in exactly two cases. While the gateway that registered the agent keeps
running the credential is the existing one; after a gateway restart it is a new one
(see below). Proving possession of an agent key shows who the
agent is, and that is enough to take over its credential only when no one else can
compute that key:
- An organization-bound agent, when the process presents a registration key bound to the record’s organization. The operator’s key is what authorizes it.
- A keyless (org-less) agent, by possession of its own key, when its first
registration declared that key to be random and held by the agent. The SDK and the
CLI generate a random identity key per agent identity and keep it owner-only in the
state directory (
AASM_STATE_DIR), and say so on every registration (RegisterRequest.durable_identity_key). The gateway records that when it creates the record and reads it back from the stored record, never from a later request, and the later process must declare a held key too. A process restarted from the same state directory proves possession of the same key and keeps the agent’s id and credential; no operator key is involved.
An identifier alone never authorizes either: the identifier is public, the key is not.
A key confined to one team (team_id on the key) re-binds only agents of that
team; it is compared with the team stored on the record, not the team_id the
caller sends. An org-wide key re-binds any team’s agent in its organization.
Every other case that reaches the existing record is the same AlreadyExists a plain
duplicate gets, with no credential in the response:
- the record has no organization and did not declare a durable key, so its key may be computable from a public identifier (registered by a client that predates the declaration);
- the record is a row written by a gateway older than the restart re-bind (AAASM-6451), which did not store the agent’s key: nothing shows the caller holds the key the record was created with;
- the caller is an unbound admin key merely asserting the organization;
- the key is confined to a different team than the record’s;
- the registration is still in flight, or its durable write failed (retry).
A process with no key, or with a key bound to a different organization, does not reach the record at all: the registry identity includes the organization, so it simply registers a separate agent. A different key bound to the same organization does reach the record and re-binds only if the team rule above allows it.
A registration abandoned mid-write (the client went away) is removed again, so a later re-bind is never handed its credential. If the durable write had already completed when the request was dropped, the stored row survives until the next registration of that agent replaces it.
Limitations.
- Registration does not check the key’s scope (read, write or admin): a read-scope org-bound key can re-bind, as it can register.
- A key revoked through REST/IAM after the agent registered still authorizes a re-bind until the gateway restarts.
- After a gateway restart an agent re-registers with the same agent id, from the
same state directory (keyless) or with its organization’s operator key, and
receives a new credential. The gateway stores the agent’s organization and public
key, never its credential, so a leaked database is not a leaked token. The
pre-restart credential stops working, and an empty token never authenticates.
Processes that re-register at the same moment normally share the one new
credential, but one of them can be refused with
AlreadyExistsif it races the install; retrying succeeds. Every rehydrated agent comes backActive, so a suspension does not survive a restart. A running process holds the old credential and does not re-register by itself: it has to be restarted after the gateway restarts (the SDK does not yet re-register onUnauthenticated). - Rows written before AAASM-6451, and records created by clients built before the
declaration, cannot be re-bound and occupy the agent’s registry slot: a later
process of that agent gets
AlreadyExistsuntil the record is removed (DELETE /api/v1/agents/:id; a record with no team needs an admin-scope key). After upgrading, every agent registered by the older gateway is in this state once. - The durable-key declaration is a claim by the client that creates the record. A hand-built client can declare it while using a computable key, so a record is only as trustworthy as its creator: whoever registers an identity first holds its credential (as before), and a later process that also declares a held key and proves possession of the same key shares that credential.
- The credential is shared: every re-bound process holds the same token, so one process’s Deregister ends the agent for all of them. Anyone who copies the agent’s key file from the state directory holds the agent’s identity and its credential.
- Another holder of the same label (another machine, a fresh state directory) has a different key and so is a different agent; the two do not merge.
Issuing and revoking keys
cargo run -p aa-auth --example aa-keygen -- --keys-file <AA_API_KEYS_PATH file> --key-out <raw key file> --org <org> [--team <team>] [--scope read|write|admin]
appends the key’s argon2 hash to the key file and writes the raw key to a separate
owner-only file; it never prints the key and rejects an empty --org.
- The gateway reads the key file once at boot. A new key, or a key you remove
from the file, takes effect when the gateway restarts. A key revoked through the
REST/IAM path stays valid for
Registeruntil that restart. - A key file that is missing or unreadable at boot leaves the gateway with an
empty key store (it logs
registration key file unreadable): org-less registration keeps working and every org claim and every key is refused. - The key’s
team_idis not enforced atRegister; only its org is.
Every refusal increments aa_register_org_rejected_total{reason}. An org-less
agent’s audit entries carry org_id = null and never match an ?org_id=
filter. team_id is never read as an organization, and entries recorded before
an agent had an org are never relabelled.
An SDK built before this field existed sends no key and no org, which is the org-less row above, so older SDKs keep registering unchanged.
Querying by Org
Audit log
# Browser / curl
curl 'http://gateway/api/v1/logs?org_id=acme&per_page=50'
# Compliance export covering one org's audit trail
aasm audit compliance-export \
--input /var/lib/aa-gateway/audit/session-<hex>.jsonl \
--org-id acme \
--format jsonl \
--output-file ./acme-audit.jsonl
GET /api/v1/logs is also bound to the caller’s own org: a key or token bound
to one org is pinned to it, admin scope included (an explicit ?org_id= for
another org returns the caller’s own org’s entries, never the other’s), and only
an admin with no org binding may choose the org.
Admin scope is cross-org on the other API routes. Only /api/v1/logs pins an
org-bound key. Other routes (violations and sandbox summaries under /api/v1/audit,
topology, agents, approvals, edges, alerts) still treat the admin scope as a global
administrator, so an admin key issued with --org can read other orgs’ data there.
Do not issue --scope admin keys with --org expecting a tenant-limited administrator;
use read or write for org-bound keys.
Audit entries written before
the agent was registered with an org_id
(or by lightweight test fixtures that bypass the registry) carry
org_id = None on Lineage and never match an explicit org_id
filter. This is intentional — multi-tenancy isolation requires explicit
Org tagging on the entry at write time.
Topology
curl 'http://gateway/api/v1/topology/overview?org_id=acme'
The overview endpoint scopes via AgentRegistry::org_members(oid). The
other topology endpoints (tree, team, lineage, stats) accept the
org_id query parameter but currently ignore it — the next ticket in
the Org-tier rollout will wire each handler.
Cross-org credential reuse detection
When an agent in Org A presents its credential but claims agent_id.org_id = "B", the gateway:
- Computes the registry key from the claimed
{org_id, team_id, agent_id}triple. Becauseorg_idis part of the hash, the claimed key differs from the agent’s actual registration key. - Looks up the claimed key — fails (no agent registered there).
- Looks up the supplied credential_token in the reverse index — finds the actual owner.
- Detects the mismatch, returns
Denywith reason"credential token registered to a different agent", and emits anA2AImpersonationAttemptedaudit event withclaimed_org_idin the payload.
A reviewer searching aasm audit list --event-type A2AImpersonationAttempted
sees these attempts grouped by the org the attacker tried to claim.
Configuring Org-tier budget limits
Operator-facing knobs live in the budget: section of any Global-scoped
policy document:
budget:
daily_limit_usd: 10000.0 # global cap across all orgs
monthly_limit_usd: 250000.0
org_daily_limit_usd: 1000.0 # AAASM-2022 — per-org daily cap
org_monthly_limit_usd: 25000.0 # AAASM-2022 — per-org monthly cap
timezone: "UTC"
action_on_exceed: deny
Semantics:
org_daily_limit_usd/org_monthly_limit_usdare uniform per-Org caps — the same envelope applies to every Org that records spend. Cross-Org isolation comes from the tracker maintaining an independentBudgetStateperorg_id, not from per-Org-customised limits.- Enforcement order in
record_costis global → org → team → agent, monthly checked before daily within each tier. The first tier that exceeds returnsBudgetStatus::LimitExceededand the deny is recorded. - Limits enter the tracker via
with_org_daily_limit/with_org_monthly_limitbuilders during policy load. Restoring from persisted snapshot preserves limits via the same path — theorg_budgetsmap is empty on first restore until the migration in AAASM-2022 follow-up lands.
Observing per-Org spend
#![allow(unused)]
fn main() {
// In-process accessor:
let alpha = budget.org_state("acme").map(|s| s.spent_usd);
}
The dashboard / CLI surfaces for aasm budget status --org <id> are
queued under AAASM-1232
follow-up subtasks.
Known gaps
- Org-scoped policy E2E:
PolicyEngine::load_from_filedoesn’t populate the scope_index, soscope: org:<id>policies need a multi-document loader — AAASM-2023. - Topology endpoints beyond
overview: tree / team / lineage / stats accept theorg_idquery param but currently ignore it. - Persistence schema for Org-tier spend: the on-disk snapshot does
not yet carry the
org_budgetsmap; a restored tracker starts with empty Org state.
The headline scenarios — audit isolation, topology overview scoping, cross-org credential rejection (AAASM-2008), and cross-org budget envelope isolation (AAASM-2022) — ship complete.
Last updated: 2026-11-09 by Chisanan232