Write and attach policies
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.
On this page
This guide creates a policy with the API, attaches it, and changes it safely. Read Policies and permissions 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.
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:
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\"]}]}"}'{"valid": false, "errors": ["statement[0]: missing 'Resource'"]}To validate a file without escaping it by hand, let jq build the body:
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.
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.
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": ["*"]
}
]
}
}'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);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"])A short name. Unique in your organization, including among deleted policies.
The name shown in the console.
Optional description.
The policy document, as a JSON object. A document sent as a string is stored as a string and grants nothing.
The response is 201 with the policy:
{
"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 ("is_system": true), managed first, then by name. GET /v1/policies/<policy-id> returns one policy, or 404 with {"error": "policy not found"}.
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 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.
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>"}'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_... |
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.
The response is 201 with the attachment:
{
"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.
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.
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. 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.
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.
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 instead.
Manage versions
A policy can have several versions. Exactly one is the default, and only the default applies.
Create a version
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": ["*"]}
]
}
}'The version number. Hexel Studio does not assign it: choose a number this policy doesn't have yet. The first version is 1.
The document of this version, as a JSON object.
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.
The response is 201:
{
"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"
}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.
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.
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 and assign the set with a scope.
- 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_idscan name your policies and managed policies. The response is201with the permission set, whose ID starts withpsd_. - 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" }'principal_typestringbodyrequiredUSER,SERVICE_USERorGROUP.principal_idstringbodyrequiredThe user ID, service user ID or group ID.
workspace_idstringbodyGrant only in this workspace and its environments. Omit both
workspace_idandenvironment_idto grant organization-wide.environment_idstringbodyGrant only in this environment. Set
workspace_idto the environment's workspace as well: an assignment with an environment and no workspace grants nothing on API calls.expires_atstringbodyOptional end of the grant, as an RFC 3339 timestamp. Without it the assignment lasts until you remove it.
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.
The principal now has the set's policies in that workspace, for requests that name it in X-Workspace-Id (see 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>/assignmentslists the set's assignments in your organization.DELETE /v1/permission-sets/<permission-set-id>/assignments/<assignment-id>removes one and its access. The response is204.PATCH /v1/permission-sets/<permission-set-id>changesname,display_nameordescription.- 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.