---
title: Test access
description: Ask Hexel Studio whether a member or service user may perform an
  action, see each step of the decision, check several actions at once, and
  validate policy documents.
url: https://hexelstudio.com/docs/identity/test-access
---

The `/v1/authz` endpoints answer "may this principal do this?" without making the call. Use them to check a policy before you attach it widely, or to find out why a call was denied.

| Endpoint | Answers |
| --- | --- |
| [`POST /v1/authz/evaluate`](#evaluate-one-action) | Allowed or denied for one action, with the reason |
| [`POST /v1/authz/explain`](#explain-a-decision) | The same decision, step by step |
| [`POST /v1/authz/simulate`](#simulate-several-actions) | Allowed or denied for a list of actions |
| [`GET /v1/authz/expand`](#list-principals-with-direct-grants) | Some of the principals that have access to a resource |
| [`POST /v1/authz/validate-policy`](#validate-a-policy-document) | Whether a policy document is well formed |

Every endpoint needs `iam:authz:simulate`. Owners and admins hold it by default.

## How these results differ from real calls

These endpoints use the **full evaluator**. Your normal API calls use the **cached permission check** described in [Policies](https://hexelstudio.com/docs/identity/policies#how-a-request-is-decided). The two agree on what is allowed by policies, groups and permission sets, but the full evaluator also applies features that don't affect real calls yet:

| The full evaluator applies | Effect on these results | Effect on real calls |
| --- | --- | --- |
| `Deny` statements in policies | Can deny | None |
| Service control policies, resource control policies, permission boundaries on members and service users | Can deny | None. See [Guardrails](https://hexelstudio.com/docs/identity/guardrails) (Preview). |
| Session policies, when you send `session_id` | Can deny | None. See [Session policies](https://hexelstudio.com/docs/identity/session-policies) (Preview). |
| ABAC rules (`evaluate` and `simulate` only) | Can allow or deny | None. See [Fine-grained access](https://hexelstudio.com/docs/identity/fine-grained-access) (Preview). |

So a result of `policy_denied`, `scp_denied`, `rcp_denied`, `boundary_exceeded`, `session_policy_denied` or `abac_denied` describes what Hexel Studio will enforce later, not what happens today: the real call may succeed.

There are other differences to keep in mind:

- **Workspace and environment.** These endpoints don't check that `workspace_id` and `environment_id` exist in your organization. A real call that names a workspace or environment that isn't active in your organization is denied.
- **Actions checked at organization level.** Some actions, such as `workspace:workspace:create`, are always checked at organization level by the real API. Test them without `workspace_id`.
- **Roles.** You can test members and service users, not roles. A role ID as `subject_id` returns `user_suspended`.
- **Caching.** `evaluate` and `simulate` results can be up to 30 seconds old. `explain` is never cached.

When the subject has no `Deny` statements and none of the Preview features apply, and you name the same scope as the real call, the answer matches what the real call gets.

## Evaluate one action

<CodeGroup>

```bash curl
curl -X POST https://api.hexelstudio.com/v1/authz/evaluate \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "subject_id": "<user-id>",
    "organization_id": "'"$ORG_ID"'",
    "action": "env:env:create",
    "workspace_id": "'"$WORKSPACE_ID"'"
  }'
```

```typescript TypeScript
const res = await fetch("https://api.hexelstudio.com/v1/authz/evaluate", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ACCESS_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    subject_id: "<user-id>",
    organization_id: process.env.ORG_ID,
    action: "env:env:create",
    workspace_id: process.env.WORKSPACE_ID,
  }),
});
if (!res.ok) {
  throw new Error(`Evaluate failed: ${res.status} ${await res.text()}`);
}
const { allowed, reason } = await res.json();
console.log(allowed, reason);
```

```python Python
import os
import requests

resp = requests.post(
    "https://api.hexelstudio.com/v1/authz/evaluate",
    headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"},
    json={
        "subject_id": "<user-id>",
        "organization_id": os.environ["ORG_ID"],
        "action": "env:env:create",
        "workspace_id": os.environ["WORKSPACE_ID"],
    },
    timeout=10,
)
resp.raise_for_status()
result = resp.json()
print(result["allowed"], result["reason"])
```

</CodeGroup>

<ParamField body="subject_id" type="string">
  The user ID of a member of your organization, or the ID of one of its service users (`su_...`). Leave it out to test yourself. A person or service user outside your organization returns `404`.
</ParamField>
<ParamField body="organization_id" type="string">
  Optional. The test always runs in your token's organization; if you send this field, it must be that organization's ID (`403` otherwise).
</ParamField>
<ParamField body="action" type="string" required>
  The action to test, for example `workspace:workspace:delete`. See the [actions reference](https://hexelstudio.com/docs/identity/actions).
</ParamField>
<ParamField body="workspace_id" type="string">
  The workspace the call would be made in. Grants placed on this workspace count.
</ParamField>
<ParamField body="environment_id" type="string">
  The environment the call would be made in. Send `workspace_id` with it.
</ParamField>
<ParamField body="resource_type" type="string">
  A resource type. Only resource control policies (Preview) read it.
</ParamField>
<ParamField body="resource_id" type="string">
  A resource ID. Only resource permissions (Preview) read it.
</ParamField>
<ParamField body="session_id" type="string">
  An access token's session ID (`sid` claim), to apply that session's session policies (Preview).
</ParamField>
<ParamField body="ip" type="string">
  Request context for ABAC rules (Preview).
</ParamField>
<ParamField body="user_agent" type="string">
  Request context for ABAC rules (Preview).
</ParamField>

```json
{
  "allowed": true,
  "reason": "policy_allow",
  "cacheable": true,
  "principal": "<user-id>",
  "action": "env:env:create",
  "resource_type": "",
  "workspace_id": "ws_2c3d4e...",
  "environment_id": "",
  "evaluation_ms": 14
}
```

<ResponseField name="allowed" type="boolean">
  The decision.
</ResponseField>
<ResponseField name="reason" type="string">
  Why. See [Reasons](#reasons).
</ResponseField>
<ResponseField name="cacheable" type="boolean">
  Whether Hexel Studio may reuse this result for the same request for up to 30 seconds.
</ResponseField>
<ResponseField name="principal" type="string">
  The subject that was evaluated.
</ResponseField>
<ResponseField name="evaluation_ms" type="integer">
  Time taken, in milliseconds. `0` when the result came from the cache.
</ResponseField>

`action`, `resource_type`, `resource_id`, `workspace_id` and `environment_id` echo the request.

### Reasons

The steps run in this order, and the first one that decides ends the evaluation:

| Order | Reason | `allowed` | Meaning |
| --- | --- | --- | --- |
| 1 | `user_suspended` | `false` | The subject is not an active account or service user. Also returned for unknown IDs and roles. |
| 2 | `org_suspended` | `false` | The organization is not active, or the member's membership is not active. |
| 3 | `scp_denied` | `false` | A service control policy denies the action (Preview). |
| 4 | `rcp_denied` | `false` | A resource control policy for `resource_type` denies the action (Preview). |
| 5 | `boundary_exceeded` | `false` | The subject's permission boundary doesn't allow the action (Preview). |
| 6 | `session_policy_denied` | `false` | The session's session policies don't allow the action (Preview). `session_policy_unavailable` means they couldn't be read. |
| 7 | `policy_denied` | `false` | A `Deny` statement in a policy that applies to the subject matches. Not enforced on real calls yet. |
| 9 | `policy_allow` | `true` | An `Allow` statement matches. This is what real calls check. |
| 10 | `resource_permission` | `true` | A resource permission on `resource_id` allows it (Preview). |
| 11 | `abac_allow`, `abac_denied` | `true`, `false` | An ABAC rule decides (Preview). |
| 12 | `default_deny` | `false` | Nothing allows the action. A real call is denied too. |

## Explain a decision

`POST /v1/authz/explain` takes the same body as `evaluate` and returns every step it ran. Use it to see which step decided.

```bash
curl -X POST https://api.hexelstudio.com/v1/authz/explain \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "subject_id": "<user-id>",
    "organization_id": "'"$ORG_ID"'",
    "action": "iam:policy:write"
  }'
```

```json
{
  "allowed": false,
  "reason": "default_deny",
  "steps": [
    {"step": "principal", "result": "OK", "details": "uid=<user-id> org=org_9a8b7c..."},
    {"step": "user_status", "result": "OK", "details": "ACTIVE"},
    {"step": "org_status", "result": "OK", "details": "ACTIVE"},
    {"step": "scp_check", "result": "OK", "details": "no SCP blocks this action"},
    {"step": "rcp_check", "result": "SKIP", "details": "no resource_type provided"},
    {"step": "boundary_check", "result": "SKIP", "details": "no boundary attached"},
    {"step": "session_policy", "result": "SKIP", "details": "no session"},
    {"step": "explicit_deny", "result": "OK", "details": "no explicit deny"},
    {"step": "policy_allow", "result": "NO_MATCH", "details": "no matching Allow policy"},
    {"step": "default", "result": "DENY", "details": "no policy grants access"}
  ]
}
```

Each step has a `step` name, a `result` (`OK`, `SKIP`, `ALLOW`, `DENY`, `NO_MATCH` or `FAIL`) and `details`. The `reason` values are the ones in [Reasons](#reasons). Other step names can appear; they don't change the decision for your organization.

`explain` does not run ABAC rules, so it can return `default_deny` where `evaluate` returns `abac_allow`.

## Simulate several actions

`POST /v1/authz/simulate` evaluates a list of actions for one subject in one scope. Each action is evaluated as `evaluate` would.

```bash
curl -X POST https://api.hexelstudio.com/v1/authz/simulate \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "principal_id": "su_4f5a6b...",
    "organization_id": "'"$ORG_ID"'",
    "actions": ["workspace:workspace:read", "env:env:create", "iam:policy:write"]
  }'
```

<ParamField body="principal_id" type="string">
  The user ID of a member of your organization, or the ID of one of its service users. Leave it out to test yourself. Note the name: `principal_id`, not `subject_id`.
</ParamField>
<ParamField body="organization_id" type="string">
  Optional. If you send it, it must be your token's organization (`403` otherwise).
</ParamField>
<ParamField body="actions" type="string[]" required>
  The actions to test.
</ParamField>
<ParamField body="workspace_id" type="string">
  As for `evaluate`.
</ParamField>
<ParamField body="environment_id" type="string">
  As for `evaluate`.
</ParamField>
<ParamField body="resource_type" type="string">
  As for `evaluate`.
</ParamField>
<ParamField body="resource_id" type="string">
  As for `evaluate`.
</ParamField>
<ParamField body="session_id" type="string">
  As for `evaluate`.
</ParamField>

```json
{
  "results": [
    {"action": "workspace:workspace:read", "allowed": true, "reason": "policy_allow"},
    {"action": "env:env:create", "allowed": true, "reason": "policy_allow"},
    {"action": "iam:policy:write", "allowed": false, "reason": "default_deny"}
  ]
}
```

## List principals with direct grants

`GET /v1/authz/expand` returns IDs of principals in your organization that have access through two specific kinds of grant:

- users with a [permission set](https://hexelstudio.com/docs/identity/policies#permission-sets) assigned directly to them, whatever the set and its scope, and
- principals with a resource permission on the given resource (Preview).

```bash
curl "https://api.hexelstudio.com/v1/authz/expand?resource_type=workspace&resource_id=$WORKSPACE_ID" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

<ParamField query="resource_type" type="string" required>
  The resource type. Missing: `400` with `{"error": "resource_type required"}`.
</ParamField>
<ParamField query="resource_id" type="string">
  The resource ID.
</ParamField>

```json
{"principals": ["<user-id>"]}
```

<Warning>
`expand` is not a list of everyone with access. It leaves out access from policies attached to members, groups and service users, from membership groups, and from permission sets assigned to groups or service users. To check one principal, use [`evaluate`](#evaluate-one-action) or [`simulate`](#simulate-several-actions).
</Warning>

## Validate a policy document

`POST /v1/authz/validate-policy` checks that a policy document is well formed. Send the document as a JSON string in `document`:

```bash
curl -X POST https://api.hexelstudio.com/v1/authz/validate-policy \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"document": "{\"Version\":\"2026-02-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"env:env:read\",\"Resource\":\"*\"}]}"}'
```

```json
{"valid": true, "errors": null}
```

When the document is not valid, `valid` is `false` and `errors` lists every problem, for example `"statement[0]: Effect must be 'Allow' or 'Deny'"`. Sending `document` as a JSON object instead of a string returns `400`. See [Validate a policy](https://hexelstudio.com/docs/identity/write-policies#validate-a-policy) for what is checked.

## Errors

| Status | Body | Cause |
| --- | --- | --- |
| `400` | `{"error": "..."}` | The body is not valid JSON or a field has the wrong type. |
| `400` | `{"error": "sid is not supported; name the principal with subject_id"}` | The body has a `sid` field. Use `subject_id`, and `session_id` for session policies. |
| `400` | `{"error": "subject_id is required"}` | You are calling with a role session and sent no subject. |
| `403` | `{"error": "forbidden"}` | You don't hold `iam:authz:simulate`. |
| `403` | `{"error": "organization_id must be the token's organization"}` | `organization_id` names another organization. |
| `404` | `{"error": "subject not found in this organization"}` | The subject is not a member or service user of your organization. |
| `503` | `{"error": "membership lookup failed"}` | Membership couldn't be checked. Try again. |
