> ## 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.

# DNS permissions

> Every IAM action the DNS service checks, which call needs it, and the resources a policy can scope it to.

Every DNS endpoint checks one IAM action before it does anything. This is the
whole list — there are no others, and no endpoint skips the check.

<Info>
  The API reference shows the action on each endpoint's own page, so you do not
  have to come back here to look one up. Both come from the same place: the
  authorization call in the service, read at build time.
</Info>

## The actions

Two of these are not guessable from the endpoint, and are the reason this table
exists rather than a naming rule:

* **Exporting a zone file** needs `dns:ListRecords`, not an export action of
  its own. The export is a read of every record in the zone, so it is gated as
  one.
* **Reading the record-import outcome** needs `dns:GetZone`. It describes
  something that happened to the zone, not a record. **Discarding** it needs
  `dns:UpdateZone` — it changes what the zone records about itself, and there
  is no separate action for a piece of zone metadata.

| Action                    | Call                                                   |
| ------------------------- | ------------------------------------------------------ |
| `dns:ListZones`           | `GET /v1/zones`                                        |
| `dns:CreateZone`          | `POST /v1/zones`                                       |
| `dns:GetZone`             | `GET /v1/zones/{zone_id}`                              |
| `dns:GetZone`             | `GET /v1/zones/{zone_id}/record-import`                |
| `dns:UpdateZone`          | `PATCH /v1/zones/{zone_id}`                            |
| `dns:UpdateZone`          | `DELETE /v1/zones/{zone_id}/record-import`             |
| `dns:DeleteZone`          | `DELETE /v1/zones/{zone_id}`                           |
| `dns:VerifyZoneOwnership` | `POST /v1/zones/{zone_id}/verify-ownership`            |
| `dns:ImportZoneFile`      | `POST /v1/zones/{zone_id}/import`                      |
| `dns:ListRecords`         | `GET /v1/zones/{zone_id}/records`                      |
| `dns:ListRecords`         | `GET /v1/zones/{zone_id}/export`                       |
| `dns:CreateRecord`        | `POST /v1/zones/{zone_id}/records`                     |
| `dns:GetRecord`           | `GET /v1/zones/{zone_id}/records/{record_id}`          |
| `dns:UpdateRecord`        | `PATCH /v1/zones/{zone_id}/records/{record_id}`        |
| `dns:DeleteRecord`        | `DELETE /v1/zones/{zone_id}/records/{record_id}`       |
| `dns:ListVPCAssociations` | `GET /v1/zones/{zone_id}/vpc-associations`             |
| `dns:AssociateVPC`        | `POST /v1/zones/{zone_id}/vpc-associations`            |
| `dns:DissociateVPC`       | `DELETE /v1/zones/{zone_id}/vpc-associations/{vpc_id}` |

## Resources

DNS actions are checked against one of two resource shapes:

```
crn:dns::<account>:zone/<zone-name>
crn:dns::<account>:zone/<zone-name>/record/<record-id>
```

The region slot is empty because DNS is global — there is one endpoint and one
set of zones, not one per region.

A record is identified by its **UUID**, not by its name and type. Record names
legitimately contain `*`, which is a wildcard in a CRN too, so a policy written
against `record/*.example.com` would match far more than the name it looks
like.

## Writing a policy

Read-only across every zone in the account:

```json theme={null}
{
  "version": "2024-01-01",
  "statements": [
    {
      "sid": "ReadDNS",
      "effect": "allow",
      "actions": [
        "dns:ListZones",
        "dns:GetZone",
        "dns:ListRecords",
        "dns:GetRecord"
      ],
      "resources": ["crn:dns::my-account:zone/*"]
    }
  ]
}
```

Full control of one zone and nothing else:

```json theme={null}
{
  "version": "2024-01-01",
  "statements": [
    {
      "sid": "OwnOneZone",
      "effect": "allow",
      "actions": ["dns:*"],
      "resources": [
        "crn:dns::my-account:zone/example.com",
        "crn:dns::my-account:zone/example.com/record/*"
      ]
    }
  ]
}
```

<Warning>
  **Both resource lines are needed.** `zone/example.com` does not cover the
  records beneath it — a record's CRN is a longer path, and a wildcard match
  stops at the resource it names. A policy with only the first line lets
  somebody read and delete the zone but not touch a single record in it.
</Warning>

`dns:ListZones` is checked against the account rather than a named zone, so it
cannot be scoped to one zone. Granting it grants the ability to see that every
zone in the account exists, though not to read what is in them.

## What a denial looks like

A failed check answers `403`:

```json theme={null}
{
  "error": {
    "code": "ACCESS_DENIED",
    "message": "You don't have permission to perform this action",
    "request_id": "..."
  }
}
```

**It does not tell you which action was missing**, deliberately — the message
is the same for every denial, so it cannot be used to map out what a credential
can and cannot reach. That is why the table above exists: look up the call you
made, and the action it needs is the one to add.

<Note>
  A `404` is not a disguised `403`. Ownership and authorization are separate
  checks: a zone belonging to another account answers `404` because it is not
  yours to see, and one you own but lack the action for answers `403`. If you
  are getting `404` on a zone you believe you own, check the account the
  credential belongs to before checking the policy.
</Note>
