---
title: Roles and assuming a role
description: Create roles, control who can assume them with trust policies, get
  temporary role sessions, work across organizations, and revoke sessions.
url: https://hexelstudio.com/docs/identity/roles
---

A role is an identity with its own policies and no credentials of its own. A member, a service user or another role **assumes** the role and gets a **role session**: a short-lived access token that has the role's permissions, and only those, for up to 12 hours.

Use roles to:

- Give temporary, elevated access instead of standing permissions.
- Confine a workload to one workspace or environment.
- Let a principal of another organization act in yours, with both organizations agreeing.

Three things decide what a role does:

| Part | Decides | Set with |
| --- | --- | --- |
| Policies attached to the role | What a session may do | [`POST /v1/policies/{policy_id}/attachments`](#attach-policies) |
| Trust policy | Who may assume the role | [`PUT /v1/roles/{role_id}/trust-policy`](#trust-policies) |
| Scope and maximum session duration | Where and for how long | [`POST /v1/roles`](#create-a-role) |

## Permissions

| Operation | Action |
| --- | --- |
| Create, update, disable or delete a role; revoke its sessions | `iam:role:write` |
| Set a trust policy (or create a role with one) | `iam:role:write_trust` |
| Read a trust policy, list a role's policies, simulate | `iam:role:read` |
| Attach a policy to a role | `iam:policy:write` |
| List or read roles | Any caller authenticated in the organization |

Assuming a role in your own organization needs no permission of your own: the role's trust policy decides. [Cross-organization](#cross-organization-access) assumption also needs `sts:role:assume` in your own organization.

## Create a role

```bash
curl -X POST https://api.hexelstudio.com/v1/roles \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "prod-deployer",
    "display_name": "Production deployer",
    "description": "Deploys to the production environment",
    "scope": "environment",
    "workspace_id": "'"$WORKSPACE_ID"'",
    "environment_id": "'"$ENVIRONMENT_ID"'",
    "max_session_duration": 3600
  }'
```

<ParamField body="name" type="string" required>
  3 to 100 characters.
</ParamField>
<ParamField body="display_name" type="string" required>
  3 to 200 characters.
</ParamField>
<ParamField body="description" type="string">
  Up to 500 characters.
</ParamField>
<ParamField body="scope" type="string" required>
  `organization`, `workspace` or `environment`. It limits where a session's permissions apply. See [Using a role session](#using-a-role-session).
</ParamField>
<ParamField body="workspace_id" type="string">
  Required for `workspace` and `environment` roles. Not allowed for `organization` roles.
</ParamField>
<ParamField body="environment_id" type="string">
  Required for `environment` roles, together with `workspace_id`. Not allowed otherwise.
</ParamField>
<ParamField body="max_session_duration" type="integer" default="3600">
  The longest session the role can issue, in seconds: 900 (15 minutes) to 43200 (12 hours).
</ParamField>
<ParamField body="trust_policy" type="object">
  Optional [trust policy](#trust-policies). Needs `iam:role:write_trust`. Without one, nobody can assume the role until you set it.
</ParamField>
<ParamField body="boundary_id" type="string">
  Optional permission boundary that caps the role's sessions. See [Guardrails](https://hexelstudio.com/docs/identity/guardrails) (Preview).
</ParamField>

The response is `201` with the role:

```json
{
  "id": "role_5b4a3c2d1e0f9a8b7c6d5e4f3a2b1c0d",
  "name": "prod-deployer",
  "display_name": "Production deployer",
  "description": "Deploys to the production environment",
  "scope": "environment",
  "is_system": false,
  "organization_id": "<org-id>",
  "created_by_user_id": "<user-id>",
  "created_at": "2026-10-20T10:00:00Z",
  "updated_at": "2026-10-20T10:00:00Z",
  "role_type": "customer",
  "trust_policy": {"Version": "2026-02-17", "Statement": []},
  "max_session_duration": 3600,
  "is_enabled": true,
  "workspace_id": "<workspace-id>",
  "environment_id": "<environment-id>"
}
```

List roles with `GET /v1/roles` (optionally `?scope=workspace`), and read one with `GET /v1/roles/{role_id}`. The list also includes roles that Hexel Studio manages, including roles it creates in your organization for its own services. Those are read-only: you can't update, delete or revoke them.

### Update a role

`PATCH /v1/roles/{role_id}` changes `display_name`, `description`, `max_session_duration`, `is_enabled` and `boundary_id`. The name, scope, workspace and environment can't change. Change the trust policy with [`PUT .../trust-policy`](#set-a-trust-policy).

```bash
curl -X PATCH https://api.hexelstudio.com/v1/roles/<role-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"max_session_duration": 7200}'
```

## Attach policies

A session has exactly the permissions of the policies attached to the role. Attach one with the policy attachments endpoint and `target_type` `ROLE`:

```bash
curl -X POST https://api.hexelstudio.com/v1/policies/<policy-id>/attachments \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"target_type": "ROLE", "target_id": "<role-id>"}'
```

As with any attachment, you can grant only actions you hold yourself. List a role's attachments with `GET /v1/roles/{role_id}/policies`. See [Write and attach policies](https://hexelstudio.com/docs/identity/write-policies).

## Trust policies

A trust policy is a JSON document on the role that says who may assume it. It is separate from the policies that say what a session may do.

```json
{
  "Version": "2026-02-17",
  "Statement": [
    {
      "Sid": "DeployersAndCI",
      "Effect": "Allow",
      "Action": "sts:role:assume",
      "Principal": {
        "Group": ["<group-id>"],
        "ServiceUser": ["<service-user-id>"]
      }
    },
    {
      "Sid": "NoBots",
      "Effect": "Deny",
      "Action": "sts:role:assume",
      "Principal": {"Group": ["<group-id>"]},
      "Condition": {"StringLike": {"ciam:session_name": ["bot-*"]}}
    }
  ]
}
```

| Field | Value |
| --- | --- |
| `Version` | Optional. Use `2026-02-17`. |
| `Statement` | Up to 20 statements. The whole document is at most 10 KB. |
| `Sid` | Optional label, returned by [simulate](#test-a-trust-policy) and recorded in the audit log. |
| `Effect` | `Allow` or `Deny`. |
| `Action` | `sts:role:assume`, as a string or an array. |
| `Principal` | Who the statement applies to. Required. |
| `Condition` | Optional conditions. See below. |

### Principals

| Key | Matches |
| --- | --- |
| `User` | These user IDs. |
| `Group` | Members of these groups. Groups must be in the role's organization. |
| `ServiceUser` | These service user IDs. |
| `Role` | Sessions of these roles, for [role chaining](#role-chaining). |
| `Org` | Any user, service user or role session whose token is for one of these organization IDs. An `Allow` with `Org` must have at least one condition. |

Values are exact IDs. Wildcards such as `*` are refused. `Federated` principals (workload identity providers) are not usable yet: see [Workload identity](https://hexelstudio.com/docs/identity/workload-identity) (Preview).

### Conditions

A condition block maps an operator to condition keys and allowed values:

```json
"Condition": {
  "StringEquals": {"ciam:principal_type": ["service_user"]},
  "StringLike": {"ciam:session_name": ["deploy-*"]}
}
```

| Key | Value |
| --- | --- |
| `ciam:principal_type` | The caller's type: `user`, `service_user` or `role`. |
| `ciam:session_name` | The [session name](#session-names) requested. |
| `ciam:source_type` | Who originally authenticated: `user` or `service_user`. Stays the same through role chaining. |
| `ciam:source_id` | The ID of that original user or service user. |
| `ciam:source_org` | That identity's organization. |

| Operator | Holds when |
| --- | --- |
| `StringEquals`, `StringLike` | The key is present and its value matches one of the values. |
| `StringNotEquals`, `StringNotLike` | The key is absent, or its value matches none of the values. |
| `Bool` | The key is present and equals `"true"` or `"false"` as given. |
| `ForAnyValue:StringEquals`, `ForAnyValue:StringLike` | At least one of the key's values matches. |

In `...Like` operators, `*` matches any run of characters and `?` matches one character. All operators and keys in a statement must hold (AND). Values in one list are alternatives (OR).

### How a trust policy is evaluated

1. Only statements whose `Action` and `Principal` match the caller count.
2. If a matching `Deny` statement's conditions hold, assumption is refused.
3. Otherwise a matching `Allow` statement whose conditions hold is required.
4. Anything else is refused.

The role must also be enabled, and the caller must be active: a suspended member, a disabled service user, or a revoked or disabled role session can't assume a role.

### Set a trust policy

`PUT /v1/roles/{role_id}/trust-policy` replaces the whole document. Read it with `GET` on the same path.

```bash
curl -X PUT https://api.hexelstudio.com/v1/roles/<role-id>/trust-policy \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d @trust-policy.json
```

The response is the updated role. An invalid document is refused with `400` and every problem found:

```json
{
  "error": "invalid trust policy",
  "problems": [
    "statement[0]: Principal values must be explicit IDs; wildcards are not allowed",
    "statement[1]: unknown condition key \"ciam:email\""
  ]
}
```

## Assume a role

To assume a role, exchange an access token you already hold at the token service, using OAuth 2.0 token exchange (RFC 8693). The token can be a member's token, a service user's token or a role session.

<CodeGroup>

```bash curl
curl -X POST https://sts.hexelstudio.com/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
    "subject_token": "'"$ACCESS_TOKEN"'",
    "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
    "resource": "<role-id>",
    "session_name": "deploy-1234",
    "duration_seconds": 1800
  }'
```

```typescript TypeScript
const res = await fetch("https://sts.hexelstudio.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
    subject_token: process.env.ACCESS_TOKEN,
    subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
    resource: "<role-id>",
    session_name: "deploy-1234",
    duration_seconds: 1800,
  }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { access_token: roleToken, expires_in } = await res.json();
```

```python Python
import os
import requests

resp = requests.post(
    "https://sts.hexelstudio.com/token",
    json={
        "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
        "subject_token": os.environ["ACCESS_TOKEN"],
        "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
        "resource": "<role-id>",
        "session_name": "deploy-1234",
        "duration_seconds": 1800,
    },
    timeout=10,
)
resp.raise_for_status()
role_token = resp.json()["access_token"]
```

</CodeGroup>

<ParamField body="grant_type" type="string" required>
  `urn:ietf:params:oauth:grant-type:token-exchange`.
</ParamField>
<ParamField body="subject_token" type="string" required>
  Your current Hexel Studio access token. It must be for an organization.
</ParamField>
<ParamField body="subject_token_type" type="string" required>
  `urn:ietf:params:oauth:token-type:access_token`.
</ParamField>
<ParamField body="resource" type="string" required>
  The ID of the role to assume (`role_...`).
</ParamField>
<ParamField body="session_name" type="string">
  A name for the session. See [Session names](#session-names).
</ParamField>
<ParamField body="duration_seconds" type="integer">
  How long the session should last. See [Session duration](#session-duration).
</ParamField>

The body can also be form-encoded (`Content-Type: application/x-www-form-urlencoded`). The response is:

```json
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 1800
}
```

There is no refresh token. When the session ends, assume the role again.

A service user can skip the separate exchange: add `resource`, and optionally `session_name` and `duration_seconds`, to its API key or client credentials request. See [Get a role session directly](https://hexelstudio.com/docs/identity/authenticate#get-a-role-session-directly). The response is the same.

| Status | `error` | Cause |
| --- | --- | --- |
| `400` | `invalid_request` | `subject_token` or `resource` is missing, or `session_name` is invalid. |
| `400` | `unsupported_token_type` | `subject_token_type` is not the access token type. |
| `400` | `invalid_target` | `resource` is not a role ID. |
| `401` | `invalid_grant` | The subject token is invalid, expired, revoked, or not for an organization. |
| `403` | `access_denied` | The trust policy or another check refused the assumption. |
| `429` | `rate_limited` | Too many tokens for this caller. See [Token endpoint limits](https://hexelstudio.com/docs/identity/authenticate#token-endpoint-limits). |
| `503` | `temporarily_unavailable` | Retry with backoff. |

A `403` doesn't say why. Use [simulate](#test-a-trust-policy) to find out, or look for `role.assume_denied` in the [audit log](https://hexelstudio.com/docs/identity/audit-logs).

### Session duration

- Without `duration_seconds`, the session lasts one hour, or the role's `max_session_duration` if that is shorter.
- With `duration_seconds`, you get that duration, capped at the role's `max_session_duration`.
- `expires_in` in the response is the duration you got.

### Session names

A session name is 2 to 64 characters from `A-Z a-z 0-9 _ = , . @ -`. It is recorded in the audit log and available to trust policy conditions as `ciam:session_name`. Use it to tell sessions apart, for example a CI run ID. Without one, a name is derived from your ID.

### Using a role session

Send the session as a bearer token:

```bash
curl https://api.hexelstudio.com/v1/workspaces \
  -H "Authorization: Bearer $ROLE_TOKEN"
```

- The session is for the role's organization. It has only the permissions of the policies attached to the role, not those of whoever assumed it.
- A `workspace` role's permissions apply only to requests for its workspace: send `X-Workspace-Id`. An `environment` role's apply only to its environment: send `X-Workspace-Id` and `X-Environment-Id`. See [Headers](https://hexelstudio.com/docs/identity/headers).
- The session stops working when it expires, when its [sessions are revoked](#revoke-sessions), when the role is disabled or deleted, or when the member or service user that started it is suspended or disabled.

## Role chaining

A role session can assume another role of the same organization. The second role's trust policy must name the first role under `Principal.Role`:

```json
{
  "Effect": "Allow",
  "Action": "sts:role:assume",
  "Principal": {"Role": ["<first-role-id>"]}
}
```

- The new session can't outlast the current one. At least 60 seconds must be left on the current session.
- The original identity carries through. `ciam:source_type`, `ciam:source_id` and `ciam:source_org` still name the member or service user who started the chain.
- A role session can't assume a role in another organization.

## Cross-organization access

A user or service user of organization A can assume a role in organization B. Both organizations must agree:

<Steps>
  <Step title="Organization B trusts the caller">
    The role's trust policy in B names the caller under `User` or `ServiceUser`, or names organization A under `Org` with at least one condition:

    ```json
    {
      "Sid": "PartnerCI",
      "Effect": "Allow",
      "Action": "sts:role:assume",
      "Principal": {"Org": ["<org-a-id>"]},
      "Condition": {"StringEquals": {"ciam:source_id": ["<service-user-id>"]}}
    }
    ```
  </Step>
  <Step title="Organization A allows the caller to assume roles">
    A policy in organization A, attached to the caller directly or through a group, allows `sts:role:assume`:

    ```json
    {
      "Version": "2026-02-17",
      "Statement": [{"Effect": "Allow", "Action": ["sts:role:assume"]}]
    }
    ```
  </Step>
  <Step title="The caller assumes the role">
    The caller exchanges its own token for organization A, with `resource` set to the role in B. The session it gets is for organization B.
  </Step>
</Steps>

If either side is missing, the request fails with `403`.

## Test a trust policy

`POST /v1/roles/{role_id}/trust-policy/simulate` runs the same decision as a real assumption, without issuing a session or changing anything. Pass `policy` to test a draft before you save it.

```bash
curl -X POST https://api.hexelstudio.com/v1/roles/<role-id>/trust-policy/simulate \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "caller": {"principal_type": "service_user", "id": "<service-user-id>"},
    "session_name": "deploy-1234"
  }'
```

<ParamField body="caller" type="object" required>
  `principal_type` (`user`, `service_user` or `role`) and `id`. The caller must belong to the role's organization. A role caller is simulated as a fresh one-hour session.
</ParamField>
<ParamField body="session_name" type="string">
  The session name to test.
</ParamField>
<ParamField body="policy" type="object">
  A draft trust policy to test instead of the saved one. It is validated like `PUT .../trust-policy`.
</ParamField>

```json
{
  "allowed": true,
  "reason": "trust_allow",
  "role_id": "<role-id>",
  "organization_id": "<org-id>",
  "scope": {"workspace_id": "<workspace-id>", "environment_id": "<environment-id>"},
  "duration": 3600,
  "matched_statement": "DeployersAndCI",
  "source": {"type": "service_user", "id": "<service-user-id>", "org": "<org-id>"}
}
```

When `allowed` is `false`, `reason` says why:

| `reason` | Meaning |
| --- | --- |
| `trust_no_match` | No `Allow` statement matched the caller and its conditions. |
| `trust_explicit_deny` | A `Deny` statement matched. `matched_statement` names it. |
| `trust_condition_error` | A `Deny` statement's conditions couldn't be evaluated, so it refuses. |
| `trust_policy_invalid` | The saved trust policy can't be read. |
| `role_disabled` | The role is disabled. |
| `caller_inactive` | The caller is suspended, disabled or no longer exists. |
| `caller_not_in_org` | The caller is not an active member of an active organization. |
| `caller_revoked` | The caller is a role session that was revoked. |
| `source_inactive` | The member or service user that started the chain is no longer active. |
| `caller_session_expiring` | The calling role session has less than 60 seconds left. |
| `caller_not_permitted` | Cross-organization: the caller's own organization doesn't allow `sts:role:assume`. |
| `cross_org_chaining_not_supported` | A role session tried to assume a role in another organization. |

Simulation can't test callers from another organization (`400`).

## Revoke sessions

To end every current session of a role, for example after a leaked token:

```bash
curl -X POST https://api.hexelstudio.com/v1/roles/<role-id>/revoke-sessions \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{"role_id": "<role-id>", "not_before": 1792490400}
```

Sessions issued before `not_before` (Unix seconds) are refused within 30 seconds, and can't be used to chain into other roles. The role stays enabled: new sessions can be assumed right away. To stop new sessions too, disable the role or change its trust policy.

<Note>
Some Hexel Studio products may accept a revoked role session until it expires.
</Note>

## Disable or delete a role

```bash
curl -X PATCH https://api.hexelstudio.com/v1/roles/<role-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"is_enabled": false}'
```

Disabling a role refuses new assumptions and revokes its current sessions. Enabling it again (`{"is_enabled": true}`) allows new sessions. Sessions revoked by the disable stay revoked.

`DELETE /v1/roles/{role_id}` revokes the role's sessions, removes its policy attachments and deletes it. The response is `204`.
