Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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
1
2
3
4
5
6
7
{
  "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 grantsdefault_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
1
2
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
1
2
3
4
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."

Updated 2026-05-18 15:01:30 View source (.md) rev 12