IAM
Authentication, identity, and fine-grained authorization: an AWS IAM-inspired policy engine with Effect, Action, Resource, and Condition statements.
Overview
Every request is authenticated via JWT (users) or an API key, and authorized entirely through attached policy documents; there is no separate project membership gate.
- Users — identity, roles, JWT (Users)
- Policy Documents — permission rules attached to users and API keys (Policies)
- Policy Engine — allow/deny resolution at request time
- Authorization Model — policy resolution per caller type (Authorization Model)
See the Permissions Reference for the IAM action strings for this module.
Related Tutorials
- Permissions in Practice - Step 4 (Create policies)
- Permissions in Practice - Step 6 (Create API keys)
- Permissions in Practice - Step 7 (Verify permissions)
Authentication
Two methods, both via Authorization: Bearer <token>.
JWT (Users)
POST /api/v1/users/login with username and password returns a signed JWT carrying the user's public ID and role. Admins bypass policy evaluation; regular users are authorized by their attached policies.
API Keys
Keys are prefixed sk_ with a key_ public ID, always scoped to a single project via project_id, optionally with their own policies. With key policies, intersection semantics apply: the owning user's policies and the key's must both allow the action, so a key never exceeds its owner. See API Keys and Permissions in Practice - Step 7 (Verify permissions).
Policy Documents
A policy document is a JSON object of statements:
{
"statement": [
{
"effect": "Allow",
"action": ["documents:GetDocument", "documents:ListDocuments"],
"resource": ["srn:proj_ABC:document:doc_XYZ"]
},
{
"effect": "Deny",
"action": ["secrets:*"],
"resource": ["srn:proj_ABC:secret:sec_PROD_KEY"]
}
]
}
Statement
| Field | Type | Required | Description |
|---|---|---|---|
effect | string | Yes | "Allow" or "Deny" |
action | string[] | Yes | Actions this statement applies to (supports wildcards) |
resource | string[] | No | SRNs this statement applies to (default: ["*"]) |
condition | object | No | Conditions that must be true for the statement to apply |
Documents are managed globally via Policies and attached to users or API keys. Worked example: Permissions in Practice - Step 4 (Create policies).
SOAT Resource Names (SRNs)
Every addressable entity has an SRN:
srn:<project_id>:<resource_type>:<resource_id>
Examples:
| SRN | Description |
|---|---|
srn:proj_ABC:document:doc_XYZ | A specific document |
srn:proj_ABC:document:* | All documents in a project |
srn:proj_ABC:file:* | All files in a project |
srn:proj_ABC:actor:actor_123 | A specific actor |
srn:*:*:* | Everything (admin-level) |
Project Segment and Policy Scoping
Policies are global, so the <project_id> segment is the primary project restriction:
resource: ["*"]— all resources in all projectsresource: ["srn:proj_ABC:*:*"]—proj_ABConlyresource: ["srn:*:document:*"]— all documents across all projects
Grant a user (JWT) a project with resource: ["srn:proj_ABC:*:*"]. API keys are scoped via project_id (see API Keys).
Resource Types
| Resource Type | Public ID Prefix | Module |
|---|---|---|
document | doc_ | Documents |
file | file_ | Files |
actor | actor_ | Actors |
conversation | conv_ | Conversations |
project | proj_ | Projects |
policy | pol_ | Policies |
api-key | key_ | API Keys |
agent | agent_ | Agents |
session | sess_ | Sessions |
memory_store | mstore_ | Memories |
tool | tool_ | Tools |
guardrail | guard_ | Guardrails |
orchestration | orch_ | Orchestrations |
dataset | dset_ | Evaluations |
eval | eval_ | Evaluations |
decider | dcd_ | Deciders |
decision | dec_ | Deciders |
generation | gen_ | Generations |
trace | trace_ | Traces |
chain | chain_ | Chains |
quota | quota_ | Quotas |
model_route | route_ | Model Routes |
ingestionRule | igr_ | Ingestion Rules |
audit | audit_ | Audit Log |
usage | uthr_ | Usage |
share | shr_ | Shares |
A child resource has no type of its own: it is named through the parent its
routes authorize against. A memory and a memory rule are named by their
memory_store; an orchestration run by its orchestration; a dataset
item by its dataset; an eval run by its eval.
Three types are spelled camelCase — aiProvider, ingestionRule and apiKey —
where every other type is snake_case. srn:<project_id>:ingestion_rule:… matches
nothing; the spelling in this table is the one that works.
Actions
Actions follow module:Operation. Full list: Permissions Reference.
Action Surface Mapping
Every action maps to one operation reachable through every client surface, e.g. actors:CreateActor:
| Surface | Convention | Example |
|---|---|---|
| Permission | module:OperationName | actors:CreateActor |
| REST endpoint | METHOD /api/v1/... | POST /api/v1/actors |
| MCP tool | kebab-case operation name | create-actor |
| CLI command | soat <kebab-case> | soat create-actor |
| SDK method | soat.<module>.<camelCase>() | soat.actors.createActor() |
A caller may invoke an operation iff the resolved policy grants the action, on any surface.
Wildcards
*— matches all actions across all modulesmodule:*— matches all actions in a specific module (e.g.,documents:*)
Conditions
Conditions add attribute-based constraints: an operator mapped to key-value pairs that must all hold.
{
"condition": {
"StringEquals": {
"soat:ResourceTag/environment": "production"
},
"StringLike": {
"soat:ResourceTag/team": "engineering-*"
}
}
}
Condition Operators
| Operator | Description |
|---|---|
StringEquals | Exact string match |
StringNotEquals | Negated exact match |
StringLike | Glob pattern match (*, ?) |
An absent key satisfies StringNotEquals and fails StringEquals: a resource carrying no team tag is not team: finance, so a StringNotEquals rule on that tag leaves it visible. The same holds whether the condition decides one resource or is compiled into a listing.
Condition Keys
| Key | Source | Description |
|---|---|---|
soat:ResourceTag/<key> | Resource tags | Tag value on the target resource |
soat:ResourceType | Request | The type of the resource being accessed |
These are the only keys a condition may name. Any other key — a typo like soat:ResourceTags/env, a different case like soat:resourcetype, or a key for a source the platform does not supply — is rejected with 400 VALIDATION_FAILED when the policy is written, and the error names the key.
The check exists because an unknown key is never present in the evaluation context, so the statement carrying it can never match: an Allow silently stops granting, and a Deny silently stops denying. The second is a fail-open, which is why a typo is refused at authoring time rather than discovered in production. The same rule already applies to action strings.
<key> after soat:ResourceTag/ is an opaque tag name and is not checked beyond being non-empty — a condition may name a tag no resource carries yet.
Operators and keys match by exact string; no case conversion applies to a condition block or to tags (see Tag keys are stored verbatim).
Authorization Model
Authorization is policy-only: every decision is evaluated against the requested action and target SRN.
Policy Resolution by Caller Type
| Caller type | Policies used |
|---|---|
| Admin (JWT) | Bypassed — admins have unrestricted access to all resources |
| Regular user (JWT) | All policies attached to the user (via User.policyIds) |
| API key (no policies) | Inherits the owning user's policies, hard-locked to the key's project |
| API key (with policies) | Intersection of user policies and key policies — both must allow the action |
| OAuth token | Intersection of user policies and the consented scope, hard-locked to the token's project |
Every API key is hard-locked to its project_id, every OAuth token to its prj; any other project is denied regardless of policy or role. An admin owner cannot cross a scoped credential's project boundary: admin lifts the policy ceiling within scope and passes the role-gated project create/delete, never the scope binding, so a cross-project resource write returns 403 API_KEY_PROJECT_SCOPE. See Project scope is a hard boundary, even for admins.
Why Intersection Semantics Matter
A key with policies, or an OAuth token with a consented scope, never exceeds its owning user's permissions: the user's policies are the ceiling. This is what makes API keys and OAuth tokens safe to delegate; one evaluator enforces every credential type.
Authorization by Caller Type
| Scenario | Result | Reason |
|---|---|---|
| Admin accessing any resource | Allowed | Admins bypass policy evaluation |
User with resource: ["srn:proj_A:*:*"] accessing proj_A | Allowed | Policy covers the SRN |
User with resource: ["srn:proj_A:*:*"] accessing proj_B | Denied | Policy does not cover proj_B SRN |
| API key scoped to proj_A, accessing proj_B | Denied | Key is hard-locked to proj_A |
| API key with key policy allowed, but user policy denied | Denied | Intersection semantics — both must allow |
| API key without policies, accessing resource allowed by user policy | Allowed | Key inherits user permissions |
What a Denial Looks Like
A denial's status code depends on what the route does, not on which policy failed:
| Route shape | Denied response |
|---|---|
List (GET /agents) | 200 with an empty list — the caller may read zero projects, so nothing matches |
Read one (GET /agents/{id}) | 404 RESOURCE_NOT_FOUND — existence is not leaked for a resource the caller can't see |
Write / act on one (PATCH /agents/{id}, POST .../release/promote) | 403 FORBIDDEN |
| Write / act on one, in a project none of the caller's policies name | 404 RESOURCE_NOT_FOUND — as above, a refusal must not confirm existence either |
Create (POST /agents) | 403 FORBIDDEN |
| Scoped credential targeting another project | 403 API_KEY_PROJECT_SCOPE, naming both projects |
A write is refused before body validation, so an unauthorized caller gets 403 whether the body is well-formed or not.
The 403/404 split on a write is about which project, not which resource. Being told plainly that one agent in a project you work in is off limits tells you nothing you could not already work out, so that stays 403. A resource in a project none of your policies name is 404: a 403 there would confirm it exists to someone with no business knowing it does — including to a caller whose policies name no project at all, for whom every project is someone else's. A denied write never says more than a denied read of the same resource would.
Cross-project shares
A policy grants actions inside the projects it names; it never lets one project act on another's resources. A share is the resource-side counterpart: the publishing project records that another project may invoke one of its tools or agents with a fixed set of actions, and the grantee accepts it.
The two sides authorize in their own projects. The publisher's shares:* actions are evaluated against srn:<publisher>:share:<id>, the grantee's shares:AcceptShare, shares:GetShare and shares:RevokeShare against srn:<grantee>:share:<id>. A scoped key or OAuth token acts for its own project on either side, so it creates shares only for that project and accepts them only into it.
A share grants only what its type allows — never a write action — and grants nothing while it is unaccepted, suspended or revoked.
Policy Evaluation
Policy evaluation (Layer 2) follows AWS IAM semantics:
- Default deny — if no statement matches, access is denied.
- Explicit deny wins — if any statement explicitly denies, access is denied regardless of allows.
- Allow — if at least one statement allows and no statement denies, access is granted.
Statement Matching
A statement matches a request when all of the following are true:
- At least one pattern in
actionmatches the requested action. - At least one pattern in
resourcematches the target SRN (orresourceis omitted /["*"]). - All
conditionblocks evaluate to true (orconditionis omitted).
Pattern Matching
*matches everything.module:*matches all actions in a module.srn:proj_ABC:document:*matches all documents in a project.- Wildcards apply only at segment boundaries — partial wildcards like
doc_X*are not supported. - Path-based patterns: when a resource has a
pathfield, the resource ID segment of the SRN may be a logical path. Both the resource'sidand itspathare tested when evaluating a single-resource check. Glob patterns (/reports/*) are expanded to SQLLIKEfor list queries.
Tags
Tags are key-value pairs on resources, enabling ABAC via conditions. One mechanism serves every tagged resource — actors, conversations, documents, files, sessions, memories and memory entries — so a tag is written, filtered and matched the same way everywhere:
| Surface | Rule |
|---|---|
Write (tags on create/update, PUT/PATCH …/tags) | A flat object of string values; anything else (an array, a nested object, a number) is 400 VALIDATION_FAILED, never coerced |
| Bounds | At most 50 keys, 128-character keys and 256-character values. A write past a bound is 400 VALIDATION_FAILED with meta.limit, and so is a PATCH whose merge would grow the stored bag past the key count. system.* keys are the platform's and do not count |
List filter (?tags=key:value, repeatable) | JSONB containment: every pair present with exactly that value; split on the first colon; a pair without a colon is 400 |
Knowledge search (tags in the body) | Same containment rule across documents and memory entries |
Policy condition (soat:ResourceTag/<key>) | Same pairs, read from the same column |
Every tagged resource honors soat:ResourceTag/<key> in a policy condition: actors, conversations, documents, files, sessions, memories and memory entries.
A memory entry is the one resource governed by two tag bags — its own, and its memory's. Both are evaluated, so an entry is never more visible than the memory holding it; see Memories — Tag Conditions.
Tag-Based Access Control walks the whole mechanism: tag two documents, condition a policy on the tag, and watch the listing and knowledge search narrow.
A condition applies differently to one resource than to a listing:
| Route shape | How the condition applies |
|---|---|
One resource (GET /sessions/{id}, the tag sub-endpoints) | The resource is loaded before the policy is evaluated, so its tags are the context: an Allow or a Deny condition decides the request |
A listing (GET /sessions?project_id=) | The caller is authorized for the resource type with no tag context, then the conditions compile into the query, where a Deny condition removes rows. Without ?project_id= there is no single project policy to compile and the listing is scoped by project only |
{
"tags": {
"environment": "production",
"team": "engineering",
"sensitivity": "high"
}
}
Managed via each resource's tags field or the tag sub-endpoints:
PUT /api/v1/<resource>/:id/tags Replace all tags
PATCH /api/v1/<resource>/:id/tags Merge tags
GET /api/v1/<resource>/:id/tags Get tags
The response of all three is the tag map itself, not the resource. Every list endpoint of a tagged resource accepts the same filter:
GET /api/v1/<resources>?tags=env:prod&tags=team:finance
Tag keys are stored verbatim
A tag key is an opaque label, never case-converted: stored, returned, and matched against soat:ResourceTag/<key> exactly as written, on REST, in formation templates, and over MCP.
cost_centerandcostCenterare two different tags; a policy naming one does not match a resource carrying only the other.GET .../tagsreturns the stored key verbatim; copy it intosoat:ResourceTag/<key>.
Tags and metadata
A resource carries two annotation bags, and only one of them is an authorization input.
tags | metadata | |
|---|---|---|
| Shape | A flat object of string values, bounded (50 keys, 128-character keys, 256-character values) | Any JSON object, nested, with the types it was written with; bounded by the project's storage quota, not by a count |
| Governed by | One mechanism for the write, ?tags=, knowledge search and soat:ResourceTag/<key> | A metadata schema per resource type and selector, which governs the write only |
| Read by the platform | Yes: the IAM context of every access check, containment filters, system.* provenance | Never: not in an IAM context, not in a policy condition, not in a prompt |
| Filtered by | ?tags=key:value, by containment | The structured filter grammar — equality, in, ordering — through the same containment |
| Reserved keys | system.*, refused on a caller write | None, and that is the point: nothing in the bag reaches platform state |
A label is a string, and a record's fields have types. A report's revision is an integer, its published_at a date, its reviewers a list; stored as strings, 3, 03 and 3.0 are three values to containment and every consumer parses by convention. So tags carry the labels the platform matches on, and metadata carries the record, typed at rest.
A metadata bag is refused unless it is a JSON object, with 400 VALIDATION_FAILED, at every endpoint that stores one — the column is JSONB and would keep a bare string as readily as an object, and a stored string then answers no structured filter, so an accepted bad write reads back as a row that simply matches nothing. The two endpoints that take a bag without storing one are not bound by this: knowledge search reads a filter over bags, and the metadata-schema validator answers with a verdict on a bag rather than keeping it.
Which bag a rule may read follows from who writes it. Any caller who may write a resource may put any JSON in its metadata, so a policy condition on that bag would let that caller decide what their own rows match — including out of a Deny. tags are bounded and their system.* keys are refused on a caller write, which is what makes them safe to condition on. A policy therefore names soat:ResourceTag/<key> and has no spelling for the other bag.
Examples
Full Access Policy
resource: ["*"] matches every SRN across all projects.
{
"statement": [
{
"effect": "Allow",
"action": ["*"],
"resource": ["*"]
}
]
}
Project-scoped Read-only Policy
Read access to one project's resources; attach to a user or API key.
{
"statement": [
{
"effect": "Allow",
"action": [
"projects:GetProject",
"documents:GetDocument",
"documents:ListDocuments",
"files:GetFile",
"files:ListFiles"
],
"resource": ["srn:proj_ABC:*:*"]
}
]
}
Allow All File Operations Except Delete
{
"statement": [
{
"effect": "Allow",
"action": ["files:*"],
"resource": ["srn:proj_ABC:file:*"]
},
{
"effect": "Deny",
"action": ["files:DeleteFile"],
"resource": ["srn:proj_ABC:file:*"]
}
]
}
Condition-based Access
Allow only actors tagged "internal":
{
"statement": [
{
"effect": "Allow",
"action": ["actors:GetActor"],
"resource": ["srn:proj_ABC:actor:*"],
"condition": {
"StringEquals": {
"soat:ResourceTag/visibility": "internal"
}
}
}
]
}
Users
For user identity management, roles, authentication, and bootstrap, see the Users module.