IAM roles and STS AssumeRole

DotID models roles the same way AWS IAM does: a role is an assumable identity in an account with a trust policy (who may assume it) and attached permission policies (what it may do). A caller assumes a role through STS AssumeRole and receives short-lived credentials that act with the role’s permissions in the role’s account. This is the mechanism behind cross-account access.

Authentication

Role management and STS are self-managed by the account admin. Sign requests with an account service-user access key using FGAI-HMAC-SHA256 (see [CLI & service authentication](https://docs.flexgalaxy.ai/dev/dotid/cli-authentication/)); a bearer JWT is also accepted. Your credential is pinned to one account — you manage roles only in that account.

Base URL: https://api.flexgalaxy.ai (production) or http://api.dev.fgai.test (local dev).

Roles

Method

Path

Purpose

POST

/identity/v1/accounts/{accountId}/roles

Create a role

GET

/identity/v1/accounts/{accountId}/roles

List roles

GET

/identity/v1/accounts/{accountId}/roles/{roleId}

Get a role

PUT

/identity/v1/accounts/{accountId}/roles/{roleId}

Update trust policy / description / max session

DELETE

/identity/v1/accounts/{accountId}/roles/{roleId}

Delete a role

POST

/identity/v1/accounts/{accountId}/roles/{roleId}/policies

Attach a permission policy

GET

/identity/v1/accounts/{accountId}/roles/{roleId}/policies

List attached policies

DELETE

/identity/v1/accounts/{accountId}/roles/{roleId}/policies/{policyId}

Detach a policy

Create a role

The trust_policy is required and answers “who may assume me”. name is required; description, path, and max_session_duration (seconds) are optional.

Field names on the wire are snake_case. Every DotID request and response body uses snake_case (trust_policy, role_frn, access_key_id, …). A body spelled in camelCase binds nothing and the API answers 400 with "role_frn: must not be blank" — a message that reads like a missing field when the real cause is the spelling.

{
  "name": "DeviceReader",
  "description": "Read-only device access, assumable by the ops tooling account.",
  "max_session_duration": 3600,
  "trust_policy": {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Principal": { "Account": ["acc-0a1b2c3d4e5f"] },
        "Action": "sts:AssumeRole"
      }
    ]
  }
}

The response is the created role, including its id:

{
  "id": "8b2f0a3e-1c4d-4e6f-9a01-2b3c4d5e6f70",
  "account_id": "11111111-1111-1111-1111-111111111111",
  "name": "DeviceReader",
  "trust_policy": { "…": "…" },
  "max_session_duration": 3600,
  "version": 1,
  "created_at": "2026-07-07T09:00:00Z"
}

Trust policy

Each statement admits a caller when Effect is Allow, Action includes sts:AssumeRole, the Principal block matches the caller, and every Condition is satisfied. Evaluation is fail-closed: with no matching Allow the caller is denied, and a matching Deny always wins.

Principal accepts:

Form

Meaning

"*"

Any caller.

{ "Account": ["acc-…"] }

Any principal in that account.

{ "FGAI": ["frn:acc-…:iam:user/name"] }

A specific principal, by [FRN](https://docs.flexgalaxy.ai/dev/dotid/frn-format/).

{ "FGAI": ["frn:acc-…:iam:account/acc-…"] }

A whole account (account-form FRN).

Optionally require a shared secret with an sts:ExternalId condition:

{
  "Effect": "Allow",
  "Principal": { "Account": ["acc-0a1b2c3d4e5f"] },
  "Action": "sts:AssumeRole",
  "Condition": { "StringEquals": { "sts:ExternalId": "shared-secret-xyz" } }
}

Attach a permission policy

Attach one of your account’s IAM policies by id to grant the role what it may do:

curl -fsS -X POST \
  "https://api.flexgalaxy.ai/identity/v1/accounts/$ACCOUNT_ID/roles/$ROLE_ID/policies" \
  -H "Content-Type: application/json" \
  -d '{ "policy_id": "a1b2c3d4-…" }'
  # + FGAI-HMAC-SHA256 signing headers

STS AssumeRole

Exchange your credential for temporary credentials scoped to a role.

Method

Path

Purpose

POST

/identity/v1/sts/assume-role

Assume a role → temporary credentials

Request

role_frn and role_session_name are required; duration_seconds and external_id are optional. Pass external_id when the target role’s trust policy requires one.

{
  "role_frn": "frn:acc-target00acct:iam:role/DeviceReader",
  "role_session_name": "ops-tooling-run-42",
  "duration_seconds": 3600,
  "external_id": "shared-secret-xyz"
}

duration_seconds is clamped to [900, role.max_session_duration].

Response

{
  "assumed_role_frn": "frn:acc-target00acct:iam:role/DeviceReader",
  "account_id": "22222222-2222-2222-2222-222222222222",
  "role_session_name": "ops-tooling-run-42",
  "access_key_id": "ASIA…",
  "secret_access_key": "…",
  "session_token": "…",
  "expiration": "2026-07-07T10:00:00Z"
}

The returned credentials are temporary (ASIA… key id). Use them to sign subsequent requests with FGAI-HMAC-SHA256, adding the session token as the X-FGAI-Security-Token header. Requests signed with these credentials act in the role’s account with the role’s permissions — so authorization decisions for the session resolve from the role’s attached policies (see Check authorization (PDP)).

Failures

  • A missing or invalid signature returns 401.

  • If the role does not exist or its trust policy denies the caller, the call returns 403 — the two cases are indistinguishable, so a caller cannot probe for roles in another account.

Assume a role from a workload identity (CI, machine persona)

A workload identity holds no key at all — it authenticates by presenting its IdP-issued OIDC token to the token exchange and receiving a short-lived DotID-signed bearer. A bearer is not a CLI command credential, so a machine persona that needs to transact takes one more step: it assumes a role and receives the same temporary ASIA… credential a human gets from Identity Center.

The route is a workload twin of the one above, not the same one:

Method

Path

Caller

POST

/identity/v1/sts/assume-role

An access key (FGAI-HMAC) or a console/federated JWT

POST

/identity/v1/workload/sts/assume-role

A workload token from the exchange, as Authorization: Bearer

Sending a workload token to the first route returns 401 invalid_token with unknown_realm: token-exchange — that is “wrong door”, not “denied”. The request and response bodies are identical on both routes.

One-time setup by the account admin

  1. Grant the workload identity the STS audience. Attach an IAM policy allowing iam:ExchangeToken on the STS service resource, so the workload can mint a token the STS lane accepts:

    {
      "Version": "2024-01-01",
      "Statement": [
        {
          "Sid": "ExchangeForSts",
          "Effect": "Allow",
          "Action": "iam:ExchangeToken",
          "Resource": "frn::iam:service/dotid-sts"
        }
      ]
    }
    

    This grants nothing but the right to ask. The audience puts the workload’s token on the STS lane; which roles it may assume is still decided, per role, by the trust policy below.

  2. Name the workload in the role’s trust policy. A workload identity’s principal FRN uses the workload-identity resource type — it is not a user, and a trust policy written for one can never be satisfied by the other:

    {
      "Effect": "Allow",
      "Principal": {
        "FGAI": ["frn:acc-0a1b2c3d4e5f:iam:workload-identity/<workload-identity-id>"]
      },
      "Action": "sts:AssumeRole"
    }
    
  3. Attach the role’s permissions as managed policies through POST /identity/v1/accounts/{accountId}/roles/{roleId}/policies. An assumed session resolves the role’s attached policies; an inline role policy is not read.

The call

# 1. exchange the CI OIDC token for a workload token on the STS lane
ACCESS_TOKEN=$(curl -fsS -X POST https://api.flexgalaxy.ai/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  --data-urlencode "subject_token=$OIDC_TOKEN" \
  --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
  --data-urlencode "audience=dotid-sts" | jq -r .access_token)

# 2. assume the role — no signing, the bearer IS the credential here
curl -fsS -X POST https://api.flexgalaxy.ai/identity/v1/workload/sts/assume-role \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "role_frn": "frn:acc-0a1b2c3d4e5f:iam:role/Publisher",
        "role_session_name": "ci-run-42" }'

The response is the same ASIA… credential shape as above. From here the workload signs requests with FGAI-HMAC-SHA256 exactly like any other principal — including through the fgai CLI:

fgai configure --name ci-publisher \
  --account-id "$ACCOUNT_ID" \
  --access-key-id  "$ACCESS_KEY_ID" \
  --secret-access-key "$SECRET_ACCESS_KEY" \
  --session-token "$SESSION_TOKEN" \
  --expiration "$EXPIRATION"

Record --expiration: fgai auth status then reports why a credential started returning 401 instead of the command failing with no explanation.

Telling the failures apart

Status

Meaning

401 with unknown_realm: token-exchange

Wrong route — you sent a workload token to /identity/v1/sts/assume-role. Use the /workload/ path.

403 on the /workload/ path with no credential shown

Either the token is not on the STS lane (missing the iam:ExchangeToken grant on frn::iam:service/dotid-sts, so the exchange minted a different audience), or the role’s trust policy does not name this workload.

400 role_frn: must not be blank

The body was spelled in camelCase. The wire is snake_case.

Revoke a live session at any time with POST /identity/v1/sts/revoke-session ({"access_key_id": "ASIA…"}); the credential stops working within about half a minute.

→ Manage workforce users

A common use of an assumed role is headless workforce (Identity Center) user management: attach a managed policy granting identitycenter:User:* to a role, assume it, and manage users in the organization Identity Center directory with the temporary credentials — no interactive login. See the worked recipe in Manage workforce (Identity Center) users headlessly (M2M).

See also