Roles and assuming a role
Create roles, control who can assume them with trust policies, get temporary role sessions, work across organizations, and revoke sessions.
On this page
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 |
| Trust policy | Who may assume the role | PUT /v1/roles/{role_id}/trust-policy |
| Scope and maximum session duration | Where and for how long | POST /v1/roles |
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 assumption also needs sts:role:assume in your own organization.
Create a role
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
}'3 to 100 characters.
3 to 200 characters.
Up to 500 characters.
organization, workspace or environment. It limits where a session's permissions apply. See Using a role session.
Required for workspace and environment roles. Not allowed for organization roles.
Required for environment roles, together with workspace_id. Not allowed otherwise.
The longest session the role can issue, in seconds: 900 (15 minutes) to 43200 (12 hours).
Optional trust policy. Needs iam:role:write_trust. Without one, nobody can assume the role until you set it.
Optional permission boundary that caps the role's sessions. See Guardrails (Preview).
The response is 201 with the role:
{
"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.
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:
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.
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.
{
"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 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. |
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 (Preview).
Conditions
A condition block maps an operator to condition keys and allowed values:
"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 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
- Only statements whose
ActionandPrincipalmatch the caller count. - If a matching
Denystatement's conditions hold, assumption is refused. - Otherwise a matching
Allowstatement whose conditions hold is required. - 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.
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.jsonThe response is the updated role. An invalid document is refused with 400 and every problem found:
{
"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.
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
}'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();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"]urn:ietf:params:oauth:grant-type:token-exchange.
Your current Hexel Studio access token. It must be for an organization.
urn:ietf:params:oauth:token-type:access_token.
The ID of the role to assume (role_...).
A name for the session. See Session names.
How long the session should last. See Session duration.
The body can also be form-encoded (Content-Type: application/x-www-form-urlencoded). The response is:
{
"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. 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. |
503 | temporarily_unavailable | Retry with backoff. |
A 403 doesn't say why. Use simulate to find out, or look for role.assume_denied in the audit log.
Session duration
- Without
duration_seconds, the session lasts one hour, or the role'smax_session_durationif that is shorter. - With
duration_seconds, you get that duration, capped at the role'smax_session_duration. expires_inin 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:
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
workspacerole's permissions apply only to requests for its workspace: sendX-Workspace-Id. Anenvironmentrole's apply only to its environment: sendX-Workspace-IdandX-Environment-Id. See Headers. - The session stops working when it expires, when its sessions are revoked, 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:
{
"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_idandciam:source_orgstill 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:
- Organization B trusts the caller
The role's trust policy in B names the caller under
UserorServiceUser, or names organization A underOrgwith 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>"]}} } - 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"]}] } - The caller assumes the role
The caller exchanges its own token for organization A, with
resourceset to the role in B. The session it gets is for organization B.
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.
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"
}'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.
The session name to test.
A draft trust policy to test instead of the saved one. It is validated like PUT .../trust-policy.
{
"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:
curl -X POST https://api.hexelstudio.com/v1/roles/<role-id>/revoke-sessions \
-H "Authorization: Bearer $ACCESS_TOKEN"{"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.
Disable or delete a role
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.