> ## Documentation Index
> Fetch the complete documentation index at: https://docs.basaltic.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Writing policies

> The policy document format, every condition operator, and worked examples to start from.

A policy is a JSON document of statements. Each statement says whether an
**effect** applies to a set of **actions** on a set of **resources**, optionally
gated by **conditions**.

```json theme={null}
{
  "version": "2024-01-01",
  "statements": [
    {
      "sid": "ReadInstances",
      "effect": "allow",
      "actions": ["compute:ListInstances", "compute:GetInstance"],
      "resources": ["crn:compute:*:my-account:instance/*"]
    }
  ]
}
```

`version` is always `2024-01-01`. Anything else is rejected.

## Statements

<ResponseField name="sid" type="string, optional">
  A label for your own use. It has no effect on evaluation.
</ResponseField>

<ResponseField name="effect" type="allow | deny" required>
  Lowercase. An explicit `deny` beats every `allow`, everywhere.
</ResponseField>

<ResponseField name="actions / not_actions" type="array" required>
  Exactly one of the pair. Setting both, or neither, is rejected when the
  document is saved.
</ResponseField>

<ResponseField name="resources / not_resources" type="array" required>
  Exactly one of the pair, same rule.
</ResponseField>

<ResponseField name="conditions" type="array, optional">
  All of them must hold for the statement to apply.
</ResponseField>

### Actions

Actions are `service:Action`, and `*` is the only wildcard.

```json theme={null}
"actions": ["compute:GetInstance"]        // one action
"actions": ["compute:*"]                  // every compute action
"actions": ["compute:List*", "compute:Get*"]  // reads, by convention
"actions": ["*"]                          // everything
```

### Resources

Resources are [CRNs](/iam#resource-names), with `*` as the only wildcard. The
colon and slash layout is compared literally, so the shape has to be right:

```json theme={null}
"resources": ["crn:compute:sa-saopaulo-1:my-account:instance/*"]
"resources": ["crn:compute:*:my-account:instance/*"]        // any region
"resources": ["crn:dns::my-account:zone/example.com"]       // global: empty region
"resources": ["crn:iam:::user/*"]                           // org-scoped: both empty
"resources": ["*"]                                          // anything
```

<Tip>
  Some resources are named rather than UUID-keyed, which makes a naming
  convention directly policy-able:
  `crn:certificate::my-account:certificate/prod-*`.
</Tip>

### Naming by exclusion

`not_actions` and `not_resources` cover everything **except** what they list.

<CodeGroup>
  ```json Deny — carve a hole (safe) theme={null}
  {
    "sid": "NothingOutsideMyAccount",
    "effect": "deny",
    "actions": ["*"],
    "not_resources": ["crn:compute:*:my-account:*"]
  }
  ```

  ```json Allow — grants the future (careful) theme={null}
  {
    "sid": "EverythingButIAM",
    "effect": "allow",
    "not_actions": ["iam:*"],
    "resources": ["*"]
  }
  ```
</CodeGroup>

<Warning>
  `not_actions` with `effect: allow` grants every action the patterns do not
  name — **including actions that do not exist yet**, added by services shipped
  after the policy was written. Pairing exclusion with `deny` carves a hole out
  of a broad allow and has no such surprise. Prefer that.
</Warning>

## Conditions

A condition compares a **context key** against **values** using an
**operator**. Every condition on a statement must hold for it to apply.

```json theme={null}
{
  "effect": "allow",
  "actions": ["compute:*"],
  "resources": ["*"],
  "conditions": [
    { "operator": "ip_address", "key": "basalt:SourceIp", "values": ["203.0.113.0/24"] }
  ]
}
```

### Operators

| Operator                                         | Holds when                                          |
| ------------------------------------------------ | --------------------------------------------------- |
| `equals` / `not_equals`                          | The value matches / does not match any listed value |
| `starts_with` / `ends_with` / `contains`         | Substring comparison                                |
| `in` / `not_in`                                  | Membership in the list                              |
| `greater_than` / `less_than`                     | Numeric comparison                                  |
| `greater_than_or_equals` / `less_than_or_equals` | Numeric comparison, inclusive                       |
| `exists` / `not_exists`                          | The key is present / absent                         |
| `ip_address` / `not_ip_address`                  | The address falls inside / outside the listed CIDRs |

### What happens when the key is missing

This is the part that decides whether a guardrail works, so it is worth being
precise about.

<Warning>
  A condition whose context key is **absent from the request** fails — *except*
  for the negated operators, which hold.

  `not_equals`, `not_in`, `not_ip_address` and `not_exists` are satisfied by a
  request that does not carry the key at all. Every other operator asserts
  something positive about a value that is not there, so it fails closed.
</Warning>

The reason is that a deny needs to fire on the request it is guarding against.
"Deny unless the request comes from these addresses" has to catch a request
with no address — treating the missing key as *no match* would make the
guardrail fail open exactly when it matters.

### Multi-valued keys

Some context keys are **sets** rather than single values — `basalt:TagKeys` is
the set of tag keys a request carries. To compare against one, add a
`set_operator`:

<CodeGroup>
  ```json for_all_values theme={null}
  {
    "sid": "OnlyApprovedTagKeys",
    "effect": "deny",
    "actions": ["*"],
    "resources": ["*"],
    "conditions": [{
      "operator": "not_in",
      "set_operator": "for_all_values",
      "key": "basalt:TagKeys",
      "values": ["env", "owner", "cost-center"]
    }]
  }
  ```

  ```json for_any_value theme={null}
  {
    "sid": "MustCarryEnvTag",
    "effect": "allow",
    "actions": ["compute:CreateInstance"],
    "resources": ["*"],
    "conditions": [{
      "operator": "equals",
      "set_operator": "for_any_value",
      "key": "basalt:TagKeys",
      "values": ["env"]
    }]
  }
  ```
</CodeGroup>

* **`for_all_values`** holds when *every* member of the request set satisfies
  the operator. An absent or empty set holds **vacuously** — a request carrying
  no tags is not fenced by a tag-key restriction.
* **`for_any_value`** holds when *at least one* member does. An absent or empty
  set does **not** hold.

### Context keys

| Key                        | Carries                                                       |
| -------------------------- | ------------------------------------------------------------- |
| `basalt:SourceIp`          | The address the request came from. Supplied on every request. |
| `basalt:RequestTag/<key>`  | The value of a tag **being set** by this request.             |
| `basalt:ResourceTag/<key>` | The value of a tag **already on** the resource.               |
| `basalt:TagKeys`           | The set of tag keys the request carries. Multi-valued.        |

The two tag prefixes answer different questions. `ResourceTag` fences access to
things already labelled a certain way; `RequestTag` fences what a caller is
allowed to label something *as*.

## Worked examples

<AccordionGroup>
  <Accordion title="Read-only across a service" icon="eye">
    ```json theme={null}
    {
      "version": "2024-01-01",
      "statements": [{
        "sid": "ReadOnlyCompute",
        "effect": "allow",
        "actions": ["compute:List*", "compute:Get*", "compute:Describe*"],
        "resources": ["*"]
      }]
    }
    ```
  </Accordion>

  <Accordion title="Confine a team to one environment by tag" icon="tag">
    Reaches only resources already tagged `env=staging`:

    ```json theme={null}
    {
      "version": "2024-01-01",
      "statements": [{
        "sid": "StagingOnly",
        "effect": "allow",
        "actions": ["compute:*", "storage:*"],
        "resources": ["*"],
        "conditions": [
          { "operator": "equals", "key": "basalt:ResourceTag/env", "values": ["staging"] }
        ]
      }]
    }
    ```

    <Note>
      This grants nothing on an **untagged** resource: `equals` on a missing
      key fails. That is usually what you want — an unlabelled resource is not
      quietly in scope.
    </Note>
  </Accordion>

  <Accordion title="Force new resources to be labelled correctly" icon="pencil">
    A caller may create instances only while tagging them `env=staging`:

    ```json theme={null}
    {
      "version": "2024-01-01",
      "statements": [{
        "sid": "CreateOnlyAsStaging",
        "effect": "allow",
        "actions": ["compute:CreateInstance"],
        "resources": ["*"],
        "conditions": [
          { "operator": "equals", "key": "basalt:RequestTag/env", "values": ["staging"] }
        ]
      }]
    }
    ```
  </Accordion>

  <Accordion title="Fence an office network, and mean it" icon="network">
    ```json theme={null}
    {
      "version": "2024-01-01",
      "statements": [{
        "sid": "DenyOffNetwork",
        "effect": "deny",
        "actions": ["*"],
        "resources": ["*"],
        "conditions": [
          { "operator": "not_ip_address", "key": "basalt:SourceIp", "values": ["203.0.113.0/24"] }
        ]
      }]
    }
    ```

    Written as a **deny** with the **negated** operator, so it also fires on a
    request that carries no source address. The inverse — allow when
    `ip_address` matches — leaves the fence off whenever the key is absent.
  </Accordion>

  <Accordion title="A guardrail that survives broad grants" icon="shield">
    ```json theme={null}
    {
      "version": "2024-01-01",
      "statements": [{
        "sid": "NeverTouchProdCerts",
        "effect": "deny",
        "actions": ["certificate:DeleteCertificate", "certificate:RevokeCertificate"],
        "resources": ["crn:certificate::my-account:certificate/prod-*"]
      }]
    }
    ```

    Attach it anywhere in the principal's set. An explicit deny is not
    overridden by any allow, including an organization owner's implicit access.
  </Accordion>

  <Accordion title="Let a data-plane agent read one certificate's key" icon="key">
    ```json theme={null}
    {
      "version": "2024-01-01",
      "statements": [{
        "sid": "MaterialForEdge",
        "effect": "allow",
        "actions": ["certificate:GetCertificateMaterial"],
        "resources": ["crn:certificate::my-account:certificate/edge-*"]
      }]
    }
    ```

    `GetCertificateMaterial` is a separate action from reading a certificate,
    precisely so this can be granted narrowly. See
    [certificates](/certificates#certificate-material).
  </Accordion>
</AccordionGroup>

## Managed and inline policies

<Columns cols={2}>
  <Card title="Managed policy" icon="library">
    A standalone object with its own CRN, attached to any number of users,
    groups, service accounts and roles. Edit once, everywhere it is attached
    changes. This is the default choice.
  </Card>

  <Card title="Inline policy" icon="paperclip">
    Written directly onto one principal, named rather than identified, and
    deleted with it. For a one-off grant that should never be reused or
    accidentally attached elsewhere.
  </Card>
</Columns>

A managed policy is created as an object of its own:

<Tabs>
  <Tab title="Console">
    Go to **IAM → Policies** and choose **Create Policy**. **Policy Details**
    takes the **Name** and **Description**; **Policy Document** is a JSON
    editor holding the document above. Attaching it afterwards is **Attach
    Policy** on the user, group, service account or role.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST /v1/policies
    { "name": "S3ReadOnly", "document": { "version": "2024-01-01", "statements": [...] } }
    ```
  </Tab>
</Tabs>

Inline policies live under the principal:

<Tabs>
  <Tab title="Console">
    Every user, group, service account and role has an **Inline Policies**
    card with **Add Inline Policy** — a **Name** and a **Policy Document**
    JSON editor. The name identifies the policy, so it is fixed once saved and
    editing one changes only the document.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    PUT    /v1/users/{user_id}/inline-policies/{policy_name}
    GET    /v1/users/{user_id}/inline-policies
    DELETE /v1/users/{user_id}/inline-policies/{policy_name}
    ```
  </Tab>
</Tabs>

The same four routes exist for `service-accounts`, `roles` and `groups`.

Some managed policies are **system** policies, marked `is_system`. They are
maintained by the platform, shared across organizations, and cannot be edited —
attach them or don't. The console badges them **System** rather than **Custom**
and opens them as **View Policy**, with no save.

## Validation

A document is rejected on save, not silently ignored, when:

* `version` is missing or is not `2024-01-01`
* `statements` is empty
* `effect` is not `allow` or `deny`
* a statement sets both `actions` and `not_actions`, or neither
* a statement sets both `resources` and `not_resources`, or neither
* a condition has no `key`, or an unrecognised `operator` or `set_operator`

<Note>
  An unrecognised operator in a **stored** document — one saved before an
  operator was renamed, say — never matches. On an allow statement it is
  skipped; on a deny it is treated as a hard deny when the action and resource
  match, so a broken guardrail fails closed rather than open.
</Note>

## Next

<CardGroup cols={2}>
  <Card title="Permission boundaries" icon="shield" href="/iam/permission-boundaries">
    Capping what these policies can ever grant.
  </Card>

  <Card title="Roles and credentials" icon="key-round" href="/iam/roles">
    Session policies scope credentials down the same way.
  </Card>
</CardGroup>
