---
title: Access Grants
path: tutorials/access-grants
status: published
---

# Access Grants

Access grants are per-domain delegation with optional record-level restrictions. Use them when role assignments are too coarse — you want to give a specific team access to a specific zone, limited to certain record types or name patterns, optionally for a bounded time.

**Base path:** `/api/v1/domains/{domain_id}/access-grants`
**Required permission:** `access_grants:*` on the domain (typically domain admin or tenant admin)

## When to use grants vs role assignments

| Situation | Use |
|-----------|-----|
| Team always has full control of the tenant | Tenant admin role |
| Team always has full control of one zone | Domain admin role (scoped to the zone) |
| Contractor needs access to one zone for Q4 only | **Access grant with `expires_at`** |
| Team should only edit `*.dev.` records | **Access grant with `record_pattern`** |
| Service only needs to manipulate `A` and `AAAA` | **Access grant with `record_types`** |

Access grants are additive to role assignments — a user with no tenant role can still manage a domain they have a grant on.

## Shape

An access grant consists of:

- **Grant type.** `user` or `group`.
- **Grantee ID.** The user or group receiving access.
- **Role.** What level of access they get on this domain (domain_manager, record_editor, read_only, or a custom role).
- **Record pattern.** Optional. Glob-style pattern on record name (`*.dev`, `api.*`).
- **Record types.** Optional. List of types the grant applies to (`["A", "AAAA", "CNAME"]`).
- **Expires at.** Optional. Grant auto-expires at this timestamp.
- **Notes.** Free-form.

## List grants

```bash
curl https://scaidns.scailabs.ai/api/v1/domains/$DOMAIN_ID/access-grants \
  -H "X-API-Key: $SCAIDNS_API_KEY"
```

**Query parameters:**

| Param | Type | Notes |
|-------|------|-------|
| `include_expired` | boolean | Include grants past their `expires_at` |

**Response:**

```json
{
  "data": [
    {
      "id": "ag_abc123",
      "domain_id": "d_xyz",
      "grant_type": "user",
      "grantee_id": "u_contractor",
      "grantee_name": "Contractor Name",
      "grantee_email": "contractor@example.com",
      "role_id": "r_record_editor",
      "role_name": "record_editor",
      "record_pattern": "*.staging",
      "record_types": ["A", "AAAA", "CNAME"],
      "expires_at": "2026-12-31T23:59:59Z",
      "notes": "Q4 staging delegation",
      "created_at": "2026-04-01T10:00:00Z"
    }
  ],
  "total": 3,
  "domain_id": "d_xyz"
}
```

## Create a grant

```bash
curl -X POST https://scaidns.scailabs.ai/api/v1/domains/$DOMAIN_ID/access-grants \
  -H "X-API-Key: $SCAIDNS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "user",
    "grantee_id": "u_contractor",
    "role_id": "r_record_editor",
    "record_pattern": "*.staging",
    "record_types": ["A", "AAAA", "CNAME"],
    "expires_at": "2026-12-31T23:59:59Z",
    "notes": "Q4 staging delegation"
  }'
```

```python
resp = httpx.post(
    f"https://scaidns.scailabs.ai/api/v1/domains/{DOMAIN_ID}/access-grants",
    headers={"X-API-Key": os.environ["SCAIDNS_API_KEY"]},
    json={
        "grant_type": "user",
        "grantee_id": "u_contractor",
        "role_id": "r_record_editor",
        "record_pattern": "*.staging",
        "record_types": ["A", "AAAA", "CNAME"],
        "expires_at": "2026-12-31T23:59:59Z",
        "notes": "Q4 staging delegation",
    },
)
```

```typescript
await fetch(
  `https://scaidns.scailabs.ai/api/v1/domains/${domainId}/access-grants`,
  {
    method: "POST",
    headers: {
      "X-API-Key": process.env.SCAIDNS_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      grant_type: "user",
      grantee_id: "u_contractor",
      role_id: "r_record_editor",
      record_pattern: "*.staging",
      record_types: ["A", "AAAA", "CNAME"],
      expires_at: "2026-12-31T23:59:59Z",
      notes: "Q4 staging delegation",
    }),
  },
);
```

## Get, update, delete

```bash
# Get
curl https://scaidns.scailabs.ai/api/v1/domains/$DOMAIN_ID/access-grants/$GRANT_ID \
  -H "X-API-Key: $SCAIDNS_API_KEY"

# Update (change role, pattern, types, expiry, notes)
curl -X PATCH https://scaidns.scailabs.ai/api/v1/domains/$DOMAIN_ID/access-grants/$GRANT_ID \
  -H "X-API-Key: $SCAIDNS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expires_at": "2027-06-30T00:00:00Z"}'

# Revoke
curl -X DELETE https://scaidns.scailabs.ai/api/v1/domains/$DOMAIN_ID/access-grants/$GRANT_ID \
  -H "X-API-Key: $SCAIDNS_API_KEY"
```

## How patterns match

Record patterns are glob-style. Only `*` is supported (no `?` or character classes):

| Pattern | Matches |
|---------|---------|
| `*` | Anything |
| `*.staging` | `foo.staging`, `bar.staging.x`, **not** `staging` alone |
| `api.*` | `api.foo`, `api.bar.baz`, **not** `api` alone |
| `web*` | `web`, `web1`, `website`, `webapi.foo` |
| `example.com` | Exact match |

Patterns match against the record name as ScaiDNS stores it (typically the relative form — `www` for `www.example.com`).

## How record types filter

If `record_types` is present, the grant only applies when the caller is mutating those types. Reading remains unrestricted (read access is either granted or not — you can't have read-only-A).

Empty or omitted `record_types` means all types.

## Grant precedence

When a user has multiple paths to a permission:

1. Platform admin always wins.
2. Tenant admin wins for their tenant.
3. Role assignments scoped to a domain (including grants) are merged.
4. If any grant matches, access is allowed. If none match, access is denied.

Grants are strictly additive. A grant can't *reduce* someone's existing permissions — if they're already a tenant admin, a restrictive grant doesn't limit them.

## Groups

Grants can target groups. All members of the group inherit the grant.

```json
{
  "grant_type": "group",
  "grantee_id": "g_devops",
  "role_id": "r_record_editor"
}
```

When a user is added to the group, the grant applies to them. When removed, it no longer does.

## Expiry

Grants with `expires_at` in the past are ignored when computing permissions. They're not automatically deleted — use `GET /access-grants/?include_expired=false` (the default) to hide them, or clean them up periodically.

## Audit

Every grant creation, update, and revocation goes to the audit log with action `access_grant.*`. See [Audit Log](../reference/audit-log.md).

## Patterns

### "Contractor access until end of engagement"

```json
{
  "grant_type": "user",
  "grantee_id": "u_contractor",
  "role_id": "r_domain_manager",
  "expires_at": "2026-06-30T23:59:59Z",
  "notes": "Ends with engagement"
}
```

### "Team can only manage dev subdomains"

```json
{
  "grant_type": "group",
  "grantee_id": "g_dev_team",
  "role_id": "r_record_editor",
  "record_pattern": "*.dev",
  "notes": "Dev-only for this team"
}
```

### "Service manipulates only A records for load balancing"

```json
{
  "grant_type": "user",
  "grantee_id": "u_lb_service",
  "role_id": "r_record_editor",
  "record_types": ["A", "AAAA"],
  "record_pattern": "lb-*",
  "notes": "LB IP rotation service"
}
```

## Errors

| Status | Meaning |
|--------|---------|
| `400` | Invalid pattern, unknown record type, malformed expiry |
| `403` | Caller can't manage grants on this domain |
| `404` | Grant, grantee, or role not found |
| `409` | Duplicate grant (same grantee + role already exists) |
| `422` | Trying to grant a role with permissions the caller doesn't have |

## Related

- [Permissions and Access](../concepts/permissions-and-access.md) — how authorization resolves.
- [Users, Groups, and Roles](../reference/users-and-access.md) — role assignment reference.
