---
title: Write and attach policies
description: Create a policy, validate it, attach it to members, groups, service
  users and roles, manage its versions, and grant access in a single workspace
  or environment.
url: https://hexelstudio.com/docs/identity/write-policies
---

This guide creates a policy with the API, attaches it, and changes it safely. Read [Policies and permissions](https://hexelstudio.com/docs/identity/policies) first for the document language and what Hexel Studio applies today.

All calls use an access token for your organization in `Authorization: Bearer $ACCESS_TOKEN`. The organization always comes from the token: policies you create belong to it, and attachments grant only in it.

## Permissions you need

| Step | Endpoint | Action |
| --- | --- | --- |
| Validate a document | `POST /v1/authz/validate-policy` | `iam:authz:simulate` |
| Create, change or delete a policy | `POST /v1/policies`, `PATCH` and `DELETE /v1/policies/<policy-id>` | `iam:policy:write` |
| List and read policies | `GET /v1/policies`, `GET /v1/policies/<policy-id>` | None: any member of the organization |
| Create a version | `POST /v1/policies/<policy-id>/versions` | `iam:policy:write` |
| List versions | `GET /v1/policies/<policy-id>/versions` | `iam:policy:read` |
| Attach and detach | `POST /v1/policies/<policy-id>/attachments`, `DELETE .../attachments/<attachment-id>` | `iam:policy:write` |
| List attachments | `GET /v1/policies/<policy-id>/attachments` | `iam:policy:read` |
| Permission sets and assignments | `/v1/permission-sets/...` | `iam:permission_set:read` to read, `iam:permission_set:write` to change |

Owners hold every action. Admins hold all of these through IAM Admin Access. Viewers hold the read actions.

Attaching a policy, changing the document of a policy, making a version the default and assigning a permission set also require that **you already hold every action they grant**. Otherwise the call fails with `403` and lists `missing_actions`. See [Nobody grants what they don't hold](https://hexelstudio.com/docs/identity/policies#nobody-grants-what-they-dont-hold).

## Validate a policy

Hexel Studio does not check a document when you create a policy, and a document it can't read grants nothing. Check it first with `POST /v1/authz/validate-policy`. 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\":[\"workspace:workspace:read\"]}]}"}'
```

```json
{"valid": false, "errors": ["statement[0]: missing 'Resource'"]}
```

To validate a file without escaping it by hand, let `jq` build the body:

```bash
jq -n --rawfile doc policy.json '{document: $doc}' | \
  curl -X POST https://api.hexelstudio.com/v1/authz/validate-policy \
    -H "Authorization: Bearer $ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -d @-
```

A valid document returns `{"valid": true, "errors": null}`.

The validator checks that the document is JSON, has at least one statement, and that every statement has `Effect` set to `Allow` or `Deny`, a non-empty `Action` (a string or an array of strings) and a `Resource`. It does not check that action names exist, so check spelling against the [actions reference](https://hexelstudio.com/docs/identity/actions).

## Create a policy

`POST /v1/policies` creates a policy in your organization. Hexel Studio also stores the document as version 1 and makes it the default version, which is the one that applies.

<CodeGroup>

```bash curl
curl -X POST https://api.hexelstudio.com/v1/policies \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "workspace-readers",
    "display_name": "Workspace readers",
    "description": "Read workspaces and environments",
    "policy_document": {
      "Version": "2026-02-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": ["workspace:workspace:read", "env:env:read"],
          "Resource": ["*"]
        }
      ]
    }
  }'
```

```typescript TypeScript
const res = await fetch("https://api.hexelstudio.com/v1/policies", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ACCESS_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "workspace-readers",
    display_name: "Workspace readers",
    description: "Read workspaces and environments",
    policy_document: {
      Version: "2026-02-17",
      Statement: [
        {
          Effect: "Allow",
          Action: ["workspace:workspace:read", "env:env:read"],
          Resource: ["*"],
        },
      ],
    },
  }),
});
if (!res.ok) {
  throw new Error(`Create policy failed: ${res.status} ${await res.text()}`);
}
const policy = await res.json();
console.log(policy.id);
```

```python Python
import os
import requests

resp = requests.post(
    "https://api.hexelstudio.com/v1/policies",
    headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"},
    json={
        "name": "workspace-readers",
        "display_name": "Workspace readers",
        "description": "Read workspaces and environments",
        "policy_document": {
            "Version": "2026-02-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": ["workspace:workspace:read", "env:env:read"],
                    "Resource": ["*"],
                }
            ],
        },
    },
    timeout=10,
)
resp.raise_for_status()
policy = resp.json()
print(policy["id"])
```

</CodeGroup>

<ParamField body="name" type="string">
  A short name. Unique in your organization, including among deleted policies.
</ParamField>
<ParamField body="display_name" type="string">
  The name shown in the console.
</ParamField>
<ParamField body="description" type="string">
  Optional description.
</ParamField>
<ParamField body="policy_document" type="object" required>
  The policy document, as a JSON object. A document sent as a string is stored as a string and grants nothing.
</ParamField>

The response is `201` with the policy:

```json
{
  "id": "pol_6c1d0e2f...",
  "name": "workspace-readers",
  "display_name": "Workspace readers",
  "description": "Read workspaces and environments",
  "organization_id": "org_9a8b7c...",
  "policy_document": {
    "Version": "2026-02-17",
    "Statement": [
      {"Effect": "Allow", "Action": ["workspace:workspace:read", "env:env:read"], "Resource": ["*"]}
    ]
  },
  "is_system": false,
  "created_by_user_id": "<your-user-id>",
  "created_at": "2026-10-20T09:30:00.000000Z",
  "updated_at": "2026-10-20T09:30:00.000000Z"
}
```

Creating a policy grants nothing until you attach it.

| Status | Body | Cause |
| --- | --- | --- |
| `400` | `{"error": "..."}` | The body is not valid JSON. |
| `403` | `{"error": "forbidden"}` | You don't hold `iam:policy:write`. |
| `500` | `{"error": "..."}` | The name is already used in your organization, or `policy_document` is missing. |

## List and read policies

`GET /v1/policies` returns an array with your organization's policies and the [managed policies](https://hexelstudio.com/docs/identity/policies#managed-policies) (`"is_system": true`), managed first, then by name. `GET /v1/policies/<policy-id>` returns one policy, or `404` with `{"error": "policy not found"}`.

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

The `policy_document` of a policy is the document from when you created it or last changed it with `PATCH`. If you made another version the default since, [list the versions](#manage-versions) to see the document that applies.

## Attach a policy

`POST /v1/policies/<policy-id>/attachments` attaches a policy, yours or a managed one, to a principal of your organization.

```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": "GROUP", "target_id": "<group-id>"}'
```

<ParamField body="target_type" type="string" required>
  Who receives the policy. Case-insensitive.

  | Value | Target | `target_id` |
  | --- | --- | --- |
  | `USER` | A member | The member's user ID (`user_id` in the members list) |
  | `GROUP` | Every member of a group | The group ID, `grp_...` |
  | `SERVICE_USER` | A service user | The service user ID, `su_...` |
  | `ROLE` | Sessions of a role | The role ID, `role_...` |
</ParamField>
<ParamField body="target_id" type="string" required>
  The ID of the target. Groups, service users and roles must belong to your organization. Hexel Studio does not check that a `USER` target is a member, so copy the ID from the members list.
</ParamField>

The response is `201` with the attachment:

```json
{
  "id": "pa_4e5f6a7b...",
  "policy_id": "pol_6c1d0e2f...",
  "target_type": "GROUP",
  "target_id": "grp_1a2b3c...",
  "organization_id": "org_9a8b7c...",
  "attached_by_user_id": "<your-user-id>",
  "attached_at": "2026-10-20T09:31:00.000000Z"
}
```

The attachment takes effect for the target's existing tokens, usually at once and always within about a minute.

| Status | Body | Cause |
| --- | --- | --- |
| `400` | `{"error": "target must be a USER, SERVICE_USER, GROUP or ROLE of the caller's organization"}` | Unknown `target_type`, empty `target_id`, or a group, service user or role that is not in your organization. |
| `403` | `{"error": "forbidden"}` | You don't hold `iam:policy:write`. |
| `403` | `{"error": "forbidden", "reason": "cannot grant actions the caller does not hold", "missing_actions": [...]}` | The policy allows actions you don't hold. |
| `404` | `{"error": "policy not found"}` | No such policy in your organization or among the managed policies. |
| `500` | `{"error": "..."}` | The policy is already attached to this target. |

For a service user you can also use `POST /v1/service-users/<service-user-id>/policies` with `{"policy_id": "<policy-id>"}`, which needs `iam:policy:attach`. See [Service users](https://hexelstudio.com/docs/identity/service-users).

### List attachments

`GET /v1/policies/<policy-id>/attachments` returns the policy's attachments in your organization, as an array of attachment objects. For a managed policy this shows where your organization uses it, including the [membership groups](https://hexelstudio.com/docs/identity/principals#what-a-member-gets-by-default).

```bash
curl https://api.hexelstudio.com/v1/policies/<policy-id>/attachments \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

There is no endpoint that lists every policy attached to one member or group. List the attachments of each policy, or check the result with [`/v1/authz/simulate`](https://hexelstudio.com/docs/identity/test-access#simulate-several-actions). For a service user use `GET /v1/service-users/<service-user-id>/policies`, and for a role `GET /v1/roles/<role-id>/policies`.

## Detach a policy

`DELETE /v1/policies/<policy-id>/attachments/<attachment-id>` removes an attachment. Take the attachment ID from the list above.

```bash
curl -X DELETE https://api.hexelstudio.com/v1/policies/<policy-id>/attachments/<attachment-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

The response is `204`. An attachment that is not in your organization, or not of this policy, returns `404` with `{"error": "policy attachment not found"}`. Detaching needs only `iam:policy:write`: you can remove access you don't hold yourself.

## Update a policy

`PATCH /v1/policies/<policy-id>` changes your organization's policies. Managed policies return `404`.

```bash
curl -X PATCH https://api.hexelstudio.com/v1/policies/<policy-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Read workspaces, environments and service users",
    "policy_document": {
      "Version": "2026-02-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": ["workspace:workspace:read", "env:env:read", "directory:service_account:read"],
          "Resource": ["*"]
        }
      ]
    }
  }'
```

Send only the fields you change: `name`, `display_name`, `description` or `policy_document` (an object, or the document as a JSON string). The response is `200` with the policy.

A new `policy_document` replaces the document of the **default version** in place, without creating a version, and applies at once to every principal the policy is attached to. Because of that, you must hold every action the new document allows. A document Hexel Studio can't parse returns `400` with `{"error": "policy document is not valid"}`.

To keep the previous document so you can go back to it, [create a version](#manage-versions) instead.

## Manage versions

A policy can have several versions. Exactly one is the default, and only the default applies.

### Create a version

```bash
curl -X POST https://api.hexelstudio.com/v1/policies/<policy-id>/versions \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "version_number": 2,
    "is_default": true,
    "policy_document": {
      "Version": "2026-02-17",
      "Statement": [
        {"Effect": "Allow", "Action": ["workspace:workspace:read"], "Resource": ["*"]}
      ]
    }
  }'
```

<ParamField body="version_number" type="integer" required>
  The version number. Hexel Studio does not assign it: choose a number this policy doesn't have yet. The first version is `1`.
</ParamField>
<ParamField body="policy_document" type="object" required>
  The document of this version, as a JSON object.
</ParamField>
<ParamField body="is_default" type="boolean" default="false">
  `true` makes this version the default: it replaces the previous default at once. You must hold every action it allows. `false` only stores the version.
</ParamField>

The response is `201`:

```json
{
  "id": "pv_0f1e2d3c...",
  "policy_id": "pol_6c1d0e2f...",
  "version_number": 2,
  "policy_document": {
    "Version": "2026-02-17",
    "Statement": [{"Effect": "Allow", "Action": ["workspace:workspace:read"], "Resource": ["*"]}]
  },
  "is_default": true,
  "created_by_user_id": "<your-user-id>",
  "created_at": "2026-10-20T09:40:00.000000Z"
}
```

<Warning>
If you send `"is_default": true` with a `version_number` the policy already has, the request fails and the policy can be left without a default version. A policy without a default version grants nothing. Fix it by creating a version with a new number and `"is_default": true`.
</Warning>

There is no call to make an existing version the default. To go back to an earlier document, create a new version with that document and `"is_default": true`.

### List versions

`GET /v1/policies/<policy-id>/versions` returns every version, highest `version_number` first. It works for managed policies too.

```bash
curl https://api.hexelstudio.com/v1/policies/<policy-id>/versions \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

## Delete a policy

`DELETE /v1/policies/<policy-id>` deletes one of your organization's policies. It stops granting everywhere it is attached, at once. The response is `204`. Its name stays reserved in your organization.

```bash
curl -X DELETE https://api.hexelstudio.com/v1/policies/<policy-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

## Grant access in one workspace or environment

A policy attachment always applies to the whole organization. To grant a policy in one workspace or environment only, put it in a [permission set](https://hexelstudio.com/docs/identity/policies#permission-sets) and assign the set with a scope.

<Steps>
<Step title="Create a permission set">

```bash
curl -X POST https://api.hexelstudio.com/v1/permission-sets \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "staging-deployers",
    "display_name": "Staging deployers",
    "description": "Full workspace and environment access",
    "policy_ids": ["pol_ws_full", "pol_env_full"]
  }'
```

`policy_ids` can name your policies and managed policies. The response is `201` with the permission set, whose ID starts with `psd_`.

</Step>
<Step title="Assign it with a scope">

```bash
curl -X POST https://api.hexelstudio.com/v1/permission-sets/<permission-set-id>/assignments \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "principal_type": "USER",
    "principal_id": "<user-id>",
    "workspace_id": "<workspace-id>",
    "expires_at": "2026-12-31T23:59:59Z"
  }'
```

<ParamField body="principal_type" type="string" required>
  `USER`, `SERVICE_USER` or `GROUP`.
</ParamField>
<ParamField body="principal_id" type="string" required>
  The user ID, service user ID or group ID.
</ParamField>
<ParamField body="workspace_id" type="string">
  Grant only in this workspace and its environments. Omit both `workspace_id` and `environment_id` to grant organization-wide.
</ParamField>
<ParamField body="environment_id" type="string">
  Grant only in this environment. Set `workspace_id` to the environment's workspace as well: an assignment with an environment and no workspace grants nothing on API calls.
</ParamField>
<ParamField body="expires_at" type="string">
  Optional end of the grant, as an RFC 3339 timestamp. Without it the assignment lasts until you remove it.
</ParamField>

The response is `201`:

```json
{
  "id": "psa_5d6e7f80...",
  "permission_set_id": "psd_1b2c3d...",
  "principal_type": "USER",
  "principal_id": "<user-id>",
  "organization_id": "org_9a8b7c...",
  "workspace_id": "ws_2c3d4e...",
  "role_id": "role_ps_5d6e7f80...",
  "assigned_by_user_id": "<your-user-id>",
  "assigned_at": "2026-10-20T09:50:00.000000Z",
  "expires_at": "2026-12-31T23:59:59Z"
}
```

Hexel Studio creates a role for each assignment (`role_id`) and deletes it when you remove the assignment. Don't change it or attach policies to it.

</Step>
</Steps>

The principal now has the set's policies in that workspace, for requests that name it in `X-Workspace-Id` (see [scope](https://hexelstudio.com/docs/identity/policies#organization-workspace-and-environment-scope)). Hexel Studio does not check that the workspace belongs to your organization: a workspace of another organization never matches a request, so such an assignment grants nothing.

| Status | Body | Cause |
| --- | --- | --- |
| `400` | `{"error": "principal_type must be USER, SERVICE_USER or GROUP"}` | Wrong `principal_type`. |
| `400` | `{"error": "principal_id is required"}` | `principal_id` is missing. |
| `403` | `{"error": "forbidden", "reason": "cannot grant actions the caller does not hold", "missing_actions": [...]}` | The set's policies allow actions you don't hold. |
| `404` | `{"error": "not found"}` | No such permission set in your organization or among the managed sets. |
| `404` | `{"error": "policy not found"}` | The set names a policy that doesn't exist or isn't visible to your organization. |

To manage assignments:

- `GET /v1/permission-sets/<permission-set-id>/assignments` lists the set's assignments in your organization.
- `DELETE /v1/permission-sets/<permission-set-id>/assignments/<assignment-id>` removes one and its access. The response is `204`.
- `PATCH /v1/permission-sets/<permission-set-id>` changes `name`, `display_name` or `description`.
- An assignment keeps the policies the set had when you assigned it. To change them, assign a set with the policies you want and remove the old assignment.
