# IAM

> SOAT's AWS-inspired IAM engine for authentication and fine-grained authorization with Effect, Action, Resource, and Condition policy statements.

# IAM

The IAM (Identity and Access Management) module provides authentication, identity management, and fine-grained authorization for the SOAT platform. It implements an AWS IAM-inspired policy engine with structured policy statements supporting `Effect`, `Action`, `Resource`, and `Condition`.

## Overview

SOAT uses a policy-based access control model. Every API request is authenticated via JWT (for users) or an API key. Authorization is evaluated entirely through the attached **policy documents** — there is no separate project membership gate.

The IAM module covers:

- **Users** — identity management, roles, and JWT authentication (see [Users](#users) below)
- **Policy Documents** — structured permission rules attached to users and API keys (see [Policies](./policies.md))
- **Policy Engine** — evaluation logic that resolves allow/deny decisions at request time
- **Authorization Model** — how policies are resolved for each caller type (see [Authorization Model](#authorization-model) below)

> See the [Permissions Reference](../permissions.md) for the IAM action strings for this module.

## Related Tutorials

- [Permissions in Practice - Step 4 (Create policies)](/docs/tutorials/permissions#step-4--create-policies)
- [Permissions in Practice - Step 6 (Create API keys)](/docs/tutorials/permissions#step-6--create-api-keys)
- [Permissions in Practice - Step 7 (Verify permissions)](/docs/tutorials/permissions#step-7--verify-permissions)

## Authentication

SOAT supports two authentication methods. Both use the `Authorization: Bearer <token>` header.

### JWT (Users)

Users authenticate via [`POST /api/v1/users/login`](/docs/api/users/login-user) with username and password. The server returns a signed JWT containing the user's public ID and role. Admin users bypass policy evaluation and have unrestricted access. Regular users are authorized through the [policies](./policies.md) attached to their account.

### API Keys

API keys are prefixed with `sk_` and identified by a `key_`-prefixed public ID. They are always scoped to a single project via `project_id` and may optionally have their own policy list. When an API key has policies attached, authorization applies **intersection semantics**: both the owning user's policies _and_ the key's own policies must independently allow the action. This ensures API keys can never exceed the permissions of the user who created them. See [API Keys](./api-keys.md) for details, or watch intersection semantics block an escalation attempt in [Permissions in Practice - Step 7 (Verify permissions)](/docs/tutorials/permissions#step-7--verify-permissions).

## Policy Documents

A policy document is a JSON object containing one or more statements. Each statement describes a permission rule.

```json
{
  "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 |

Policy documents are created and managed globally via the [Policies](./policies.md) module and attached to users or API keys. For a worked example building both a full-access and a read-only document, see [Permissions in Practice - Step 4 (Create policies)](/docs/tutorials/permissions#step-4--create-policies).

## SOAT Resource Names (SRNs)

Every addressable entity has a canonical identifier called a SOAT Resource Name:

```
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

Because policies are **global** (not scoped to any project), the `<project_id>` segment in an SRN is the primary mechanism for restricting access to specific projects.

In practice:

- `resource: ["*"]` — matches all resources in **all projects**. Use only for broad access.
- `resource: ["srn:proj_ABC:*:*"]` — restricts access to resources in `proj_ABC` only.
- `resource: ["srn:*:document:*"]` — matches all documents across all projects.

:::tip
To give a **user** (JWT) access to a specific project, create a policy with `resource: ["srn:proj_ABC:*:*"]`. This achieves project-level scoping entirely through the policy engine. API keys are always scoped to a single project via `project_id` (see [API Keys](./api-keys.md#project-scoping)).
:::

### 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      |

## Actions

Actions follow the `module:Operation` pattern. The full list of all action strings per module is in the [Permissions Reference](../permissions.md).

### Action Surface Mapping

Every permission action corresponds to a single operation that is reachable through all four client surfaces. Given `actors:CreateActor` as an example:

| Surface           | Convention                    | Example                     |
| ----------------- | ----------------------------- | --------------------------- |
| **Permission**    | `module:OperationName`        | `actors:CreateActor`        |
| **REST endpoint** | `METHOD /api/v1/...`          | [`POST /api/v1/actors`](/docs/api/actors/create-actor)       |
| **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 is authorised to invoke an operation if — and only if — the resolved policy grants the corresponding permission action. The same check applies regardless of which surface the caller uses.

### Wildcards

- `*` — matches all actions across all modules
- `module:*` — matches all actions in a specific module (e.g., `documents:*`)

## Conditions

Conditions add attribute-based constraints to statements. A condition block maps an operator to one or more key-value pairs that must all evaluate to true.

```json
{
  "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 (`*`, `?`) |

### 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 |

Condition operators and condition keys are matched **by exact string** — no case
conversion is applied to a `condition` block or to a resource's `tags` (see
[Tag keys are stored verbatim](#tag-keys-are-stored-verbatim)).

## Authorization Model

Authorization in SOAT is **policy-only** — there is no separate project membership gate. All access decisions are evaluated through the policy engine against the requested action and the target resource 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`, and every OAuth token to its `prj`; access to any other project is denied regardless of policy — and regardless of the owner's role. An `admin` owner cannot cross a scoped credential's project boundary for resource operations: admin lifts the policy ceiling within scope and passes the role-gated project create/delete, but never the scope binding itself, so a cross-project resource write still returns `403 API_KEY_PROJECT_SCOPE`. See [Project scope is a hard boundary, even for admins](./api-keys.md#project-scope-is-a-hard-boundary-even-for-admins).

### Why Intersection Semantics Matter

When an API key has policies attached — or an OAuth token carries a consented scope — the credential can **never exceed the permissions of the user who owns it**. Even if the key's policy or the consent is very permissive, the user's policies still apply as a ceiling. This is why both [API keys](./api-keys.md) and [OAuth tokens](./oauth.md#permission-enforcement) are safe to delegate. The same evaluator enforces all credential types.

### 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`](/docs/api/agents/list-agents))                                                 | `200` with an empty list — the caller may read zero projects, so nothing matches      |
| **Read one** ([`GET /agents/{id}`](/docs/api/agents/get-agent))                                        | `404 RESOURCE_NOT_FOUND` — existence is not leaked for a resource the caller can't see |
| **Write / act on one** ([`PATCH /agents/{id}`](/docs/api/agents/patch-agent), `POST .../release/promote`) | `403 FORBIDDEN`                                                                        |
| **Create** ([`POST /agents`](/docs/api/agents/create-agent))                                             | `403 FORBIDDEN`                                                                        |
| Scoped credential targeting another project                             | `403 API_KEY_PROJECT_SCOPE`, naming both projects                                     |

A write is refused **before** the request body is validated, so a caller without
permission cannot tell a well-formed body from a malformed one: the answer is
`403` either way.

## 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 attached to resources. They enable attribute-based access control (ABAC) via conditions. Taggable resources include documents, files, actors, and conversations.

```json
{
  "tags": {
    "environment": "production",
    "team": "engineering",
    "sensitivity": "high"
  }
}
```

Tags are managed via each resource's create/update endpoints using the `tags` field, or through dedicated 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
```

### Tag keys are stored verbatim

A tag key is an opaque label, not an API field name, so — unlike every other
field in the REST API — it is **never case-converted**. It is stored, returned,
and matched against `soat:ResourceTag/<key>` exactly as you wrote it, on REST, in
formation templates, and over MCP alike.

Two consequences worth knowing:

- `cost_center` and `costCenter` are **two different tags**. A resource can carry
  both, and a policy naming one does not match a resource carrying only the other.
- The key you read back is the key to name in a condition. `GET .../tags` returns
  the stored key verbatim, so it can be copied straight into
  `soat:ResourceTag/<key>`.

## Examples

### Full Access Policy

Equivalent to unrestricted access across all projects. The `resource: ["*"]` wildcard matches all SRNs globally.

```json
{
  "statement": [
    {
      "effect": "Allow",
      "action": ["*"],
      "resource": ["*"]
    }
  ]
}
```

### Project-scoped Read-only Policy

Grants read access to a specific project's resources. Attach this to a user or API key.

```json
{
  "statement": [
    {
      "effect": "Allow",
      "action": [
        "projects:GetProject",
        "documents:GetDocument",
        "documents:ListDocuments",
        "files:GetFile",
        "files:ListFiles"
      ],
      "resource": ["srn:proj_ABC:*:*"]
    }
  ]
}
```

### Allow All File Operations Except Delete

```json
{
  "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"`:

```json
{
  "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](./users.md).
