Skip to main content

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.

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​

FieldTypeRequiredDescription
effectstringYes"Allow" or "Deny"
actionstring[]YesActions this statement applies to (supports wildcards)
resourcestring[]NoSRNs this statement applies to (default: ["*"])
conditionobjectNoConditions 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:

SRNDescription
srn:proj_ABC:document:doc_XYZA 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_123A 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 projects
  • resource: ["srn:proj_ABC:*:*"] — proj_ABC only
  • resource: ["srn:*:document:*"] — all documents across all projects
tip

Grant a user (JWT) a project with resource: ["srn:proj_ABC:*:*"]. API keys are scoped via project_id (see API Keys).

Resource Types​

Resource TypePublic ID PrefixModule
documentdoc_Documents
filefile_Files
actoractor_Actors
conversationconv_Conversations
projectproj_Projects
policypol_Policies
api-keykey_API Keys
agentagent_Agents
sessionsess_Sessions
memory_storemstore_Memories
tooltool_Tools
guardrailguard_Guardrails
orchestrationorch_Orchestrations
datasetdset_Evaluations
evaleval_Evaluations
deciderdcd_Deciders
decisiondec_Deciders
generationgen_Generations
tracetrace_Traces
chainchain_Chains
quotaquota_Quotas
model_routeroute_Model Routes
ingestionRuleigr_Ingestion Rules
auditaudit_Audit Log
usageuthr_Usage
shareshr_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:

SurfaceConventionExample
Permissionmodule:OperationNameactors:CreateActor
REST endpointMETHOD /api/v1/...POST /api/v1/actors
MCP toolkebab-case operation namecreate-actor
CLI commandsoat <kebab-case>soat create-actor
SDK methodsoat.<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 modules
  • module:* — 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​

OperatorDescription
StringEqualsExact string match
StringNotEqualsNegated exact match
StringLikeGlob 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​

KeySourceDescription
soat:ResourceTag/<key>Resource tagsTag value on the target resource
soat:ResourceTypeRequestThe 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 typePolicies 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 tokenIntersection 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​

ScenarioResultReason
Admin accessing any resourceAllowedAdmins bypass policy evaluation
User with resource: ["srn:proj_A:*:*"] accessing proj_AAllowedPolicy covers the SRN
User with resource: ["srn:proj_A:*:*"] accessing proj_BDeniedPolicy does not cover proj_B SRN
API key scoped to proj_A, accessing proj_BDeniedKey is hard-locked to proj_A
API key with key policy allowed, but user policy deniedDeniedIntersection semantics — both must allow
API key without policies, accessing resource allowed by user policyAllowedKey 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 shapeDenied 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 name404 RESOURCE_NOT_FOUND — as above, a refusal must not confirm existence either
Create (POST /agents)403 FORBIDDEN
Scoped credential targeting another project403 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:

  1. Default deny — if no statement matches, access is denied.
  2. Explicit deny wins — if any statement explicitly denies, access is denied regardless of allows.
  3. 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:

  1. At least one pattern in action matches the requested action.
  2. At least one pattern in resource matches the target SRN (or resource is omitted / ["*"]).
  3. All condition blocks evaluate to true (or condition is 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 path field, the resource ID segment of the SRN may be a logical path. Both the resource's id and its path are tested when evaluating a single-resource check. Glob patterns (/reports/*) are expanded to SQL LIKE for 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:

SurfaceRule
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
BoundsAt 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 shapeHow 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_center and costCenter are two different tags; a policy naming one does not match a resource carrying only the other.
  • GET .../tags returns the stored key verbatim; copy it into soat:ResourceTag/<key>.

Tags and metadata​

A resource carries two annotation bags, and only one of them is an authorization input.

tagsmetadata
ShapeA 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 byOne 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 platformYes: the IAM context of every access check, containment filters, system.* provenanceNever: not in an IAM context, not in a policy condition, not in a prompt
Filtered by?tags=key:value, by containmentThe structured filter grammar — equality, in, ordering — through the same containment
Reserved keyssystem.*, refused on a caller writeNone, 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.