---
summary: "The v2 NTFS-style permission model \u2014 bitmask verbs, allow/deny ACEs,\
  \ inheritance, transitive groups, single chokepoint."
title: ACLs
path: concepts/acls
status: published
---

# ACLs

Every collection and every document carries its own ACL. The ACL contains zero or more ACEs (Access Control Entries) that grant or deny specific permissions to a ScaiKey user or group. Documents inherit from their collection by default, and inheritance can be broken per resource. Resolution follows NTFS rules. Search and graph results are filtered through one chokepoint that runs the resolver on every candidate.

The v1 collection-level access endpoints (`/collections/{id}/access`) still work for backward compatibility but only operate on coarse read/write/manage grants. New integrations should use the per-resource ACL surface under `/permissions/{resource_type}/{resource_id}/...`.

## Permission verbs

Permissions are a bitmask. Pick individual bits or compose them as roles.

| Bit | Verb | Grants |
|----:|------|--------|
| 1 | `READ` | Retrieve chunks via search; fetch document content. |
| 2 | `WRITE` | Edit metadata; trigger re-embed. |
| 4 | `DELETE` | Delete the document. |
| 8 | `INGEST` | Upload new documents (collection-only). |
| 16 | `LIST` | See the resource exists without reading its content. |
| 32 | `READ_PERMISSIONS` | View the ACL. |
| 64 | `CHANGE_PERMISSIONS` | Add / remove / modify ACEs. |
| 128 | `TAKE_OWNERSHIP` | Reassign the owner. |

`INGEST` only makes sense on a collection — adding it to a document ACE is rejected at the API layer with `INVALID_ACE`.

## Composed roles

For ergonomic grants, four named roles:

| Role | Bitmask | Sum |
|------|--------:|-----|
| `VIEWER` | 49 | `READ` + `LIST` + `READ_PERMISSIONS` |
| `EDITOR` | 59 | `VIEWER` + `WRITE` + `INGEST` |
| `MANAGER` | 127 | `EDITOR` + `DELETE` + `CHANGE_PERMISSIONS` |
| `OWNER` | 255 | `MANAGER` + `TAKE_OWNERSHIP` |

You pass the integer bitmask in the `permissions` field of an ACE. The API also returns a `permission_names` array for readability.

## Allow and deny

Every ACE has an `ace_type`: `allow` or `deny`.

```json
{
  "principal_type": "group",
  "principal_id": "grp_hr",
  "ace_type": "allow",
  "permissions": 59,
  "inherit_to_children": true
}
```

A `deny` ACE for `READ` on a single document, applied to one user inside an otherwise-shared collection, removes that user's access to that document — without touching the rest of the collection. This is the v2 superpower: legal carve-outs no longer mean fragmenting collections.

## Resolution order

`AclResolver.can(user, ref, permission)` walks ACEs in canonical NTFS order:

1. **Explicit deny on the resource** — if any matching deny ACE on this exact resource includes the requested permission, deny wins.
2. **Explicit allow on the resource** — if any matching allow ACE includes the permission, allow wins.
3. **Inherited deny** — same check against the parent ACL (collection, when the resource is a document) if inheritance is on and the parent ACE has `inherit_to_children: true`.
4. **Inherited allow** — same.
5. **Implicit grants** — `default_access: "tenant"` adds a synthetic allow for the tenant principal at the lowest priority.

Bypasses (in order, highest priority):

- **`SUPER_ADMIN`** — full access to every resource in every tenant.
- **`TENANT_ADMIN`** — full access to every resource in their tenant.
- **Resource owner** — full access on the resource they own, even against explicit denies.

## Transitive groups

ScaiKey supports nested groups (groups of groups). ScaiMatrix expands them transitively when resolving — a user in group A which is a member of group B inherits any ACE that names B.

A mirror table (`mod_scaimatrix_scaikey_nested_groups`) caches the closure; a `reconcile_scaikey_state` cron job runs every ten minutes, and ScaiKey's `group.nested_added` / `group.nested_removed` webhooks fast-path updates between runs. The reconciliation backstop exists so the system stays correct even if the webhook pipeline is briefly down.

## Inheritance and breaking it

Documents inherit their collection's ACEs by default. The collection-level ACE must have `inherit_to_children: true` to flow down. You can break inheritance on a single document:

```json
PATCH /v1/modules/scaimatrix/permissions/document/{document_id}/acl
{ "inherit_from_parent": false }
```

After this, only ACEs on the document itself apply — the collection's grants no longer reach this document. The owner and SUPER_ADMIN bypasses still hold.

## Search-time filtering — the chokepoint

The correctness invariant is "search and retrieval never return data the calling user lacks `READ` on." Every search response — vector, hybrid, graph traversal, NDJSON export, list endpoints — passes results through `filter_results_by_acl` before they leave the server. Denied rows are dropped silently; the response carries fewer items than the unfiltered count.

For paginated list endpoints, both the unfiltered `total` and a post-filter `visible_count` are returned so clients can distinguish "end of data" from "page short due to ACL."

If a user loses access to a document mid-session, the next search reflects that on the next call — the chokepoint re-evaluates ACEs on every request. There's a small race window between an ACE write and the next index lookup (sub-second in practice); the resolver is the source of truth, not the index.

## Ownership

Every resource has an `owner_user_id`. The owner sees everything on the resource and is exempt from explicit denies. Transferring ownership requires `TAKE_OWNERSHIP`:

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaimatrix/permissions/collection/$COLLECTION_ID/take-ownership" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"new_owner_user_id": "usr_bob"}'
```

Every transfer is recorded in ScaiGrid's audit log.

## `default_access` on a collection

When you create a collection you set:

- `default_access: "tenant"` — every tenant member has implicit `VIEWER`. Acts like a synthetic allow ACE on a tenant-wide group; useful for shared corporate knowledge.
- `default_access: "restricted"` — no implicit grants. Only explicit ACEs are honoured.

This is a creation-time convenience that surfaces in resolution; it doesn't change the rule order.

## Why a single chokepoint instead of index-side filtering

Pushing ACL state into Weaviate or Neo4j would mean writing tenant + group + ACE updates into two more systems on every change. That's three sources of truth, two of them eventually consistent under load. One chokepoint, exhaustively property-tested, with the index returning unfiltered candidates, is cheaper and easier to reason about — the cost is a small fraction of search latency, and the failure mode is "fewer results than expected", never "more."
