Guides

Per-user permissions and audit

Control which of your users can read which documents inside a shared workspace — reader lists the server enforces on every read, principal-scoped reads that mirror client.partition(), and an access-audit log that shows who saw what, when.

If you're building an agent product where one workspace serves many end users — a support copilot a whole team shares, an assistant that remembers things about specific people — some documents shouldn't be visible to everyone who can search the store. Aether gives you three pieces that work together:

  • Reader lists label a document with exactly who may read it — users, groups, or both. Pass acl_readers on any write, and the server filters every subsequent read against the list.
  • Principal-scoped reads tell Aether which user a read is on behalf of. client.as_principal("user:alice") is one line, and it's the same scoped-handle pattern as client.partition() — every read through the handle carries the assertion.
  • The access-audit log records reads, search deliveries, and denials, so "who saw this document?" is a query, not a forensic exercise.

Reader lists are not a tenant boundary — partitions are. A partition keeps each of your end-clients' data apart; a reader list controls which users can see a document within a scope. The two compose: scope a handle to a partition and a principal, and both boundaries apply.


The trust model

Be clear-eyed about what is enforced where, because it's the difference between a security control and a decoration:

  • Your app authenticates its users. Aether never sees your users' passwords, sessions, or tokens — it does not authenticate your end users. Your app decides who the acting user is, then asserts that identity to Aether on each read.
  • Aether enforces the assertion server-side. Given an asserted principal, the server filters every read against each document's reader list — before results are returned, on every code path — and records the access (including denials) in the audit log. The filtering is not something your app can forget to do; it's not client-side.
  • The assertion is only as trustworthy as your API key. Any holder of a workspace key can assert any principal. That's the right default for a backend that serves all your users from one key, but it means the key is the thing to protect.
  • Pinned keys narrow the blast radius. A key can be pinned to a single principal (in the Dashboard). A pinned key always acts as exactly that principal — asserting anyone else fails loudly — so a leaked or misused key can't impersonate other users. See Pin a key to a principal below.

In short: you own authentication (who is this user?), Aether owns enforcement (what may they read?) and the record (what did they read?).


Label documents with reader lists

Every document is in one of two states:

  • Unlabeled — no reader list. The document is visible to every reader in its scope (the whole workspace, or its partition through a scoped handle). This is the back-compatible default: existing documents and writes that don't pass a reader list behave exactly as before.
  • Labeled — a reader list is set. The document is deny-closed: only the principals it names — user: and group: labels — can read it. Anyone else's searches never surface it, and their by-id reads behave as if the document doesn't exist. An explicit empty list quarantines the document: no principal can read it (only admin-role keys, see below).

Pass the reader list on any write — the same parameter works on insert, insert_text, insert_stream, file ingestion, batch inserts, and update:

from aether import AetherClient

client = AetherClient(api_key=os.environ["AETHER_API_KEY"])

# Only Alice and the support group can ever read this document.
client.insert_text(
    "Alice's escalation notes…",
    filename="escalation.txt",
    acl_readers=["user:alice", "group:support"],
)

A reader-list entry is a user:<id> or group:<id> label; the ids are yours — your user ids, your team names. A document is readable by a principal when the list names the principal itself or any group it acts with.

Update replaces the reader list

update replaces the stored reader list, mirroring how it treats tags: the list you pass becomes the document's ACL. Omitting acl_readers on an update doesn't preserve the existing list — it clears it, and the document goes back to unlabeled (visible to the whole scope). When you update a labeled document, always re-send its reader list.

Reads through an admin-role key bypass reader lists entirely — that's how your own back office sees quarantined documents and runs cross-user maintenance. Every bypassed read is recorded in the audit log as admin_bypass, so even the escape hatch leaves a trail.


Read as a user

To read on behalf of a user, scope a handle with as_principal — the same pattern as client.partition(), and composable with it. Every read through the handle asserts that principal (and, optionally, the groups it acts with), and the server filters results against each document's reader list:

alice = client.as_principal("user:alice", groups=["group:support"])
bob = client.as_principal("user:bob")

# Same query, different principals, different result sets.
alice_hits = alice.retrieve("escalation notes", k=5)  # includes the labeled doc
bob_hits = bob.retrieve("escalation notes", k=5)      # never surfaces it

# Composes with partition scoping — both boundaries apply.
scoped = client.partition("client_acme").as_principal("user:alice")

A principal sees unlabeled documents plus every labeled document whose reader list names it or one of its groups. What it can't see is genuinely invisible:

  • Searches and retrievals never rank, snippet, or count a document the principal can't read.
  • A denied by-id read is a 404, not a 403. A document a principal can't read is indistinguishable from a document that doesn't exist — the identical not-found error. This is deliberate: a distinct "forbidden" answer would confirm the document exists, and a document id must not be usable to probe for other users' data. There is no existence oracle.

Group membership travels with the handle: your app resolves which groups a user belongs to (from your own directory or roles table) and passes them to as_principal. Asserting a group is how one document — labeled group:support once — stays readable as people join and leave the team, without rewriting reader lists.


Audit who saw what

Reader lists control access; the access-audit log proves it. Every read through the enforcement layer is recorded — deliveries and denials — and client.audit.access(...) queries the log. This is the compliance story in one call: show who saw what, when — including who was turned away.

# Everything Alice saw (or was denied), newest first.
page = client.audit.access(actor="user:alice", limit=50)
for r in page:
    print(r.at, r.actor, r.action, r.resource, r.outcome)
print(page.total)  # total matches across all pages

# Every denial in the workspace — your anomaly feed.
denials = client.audit.access(action="denied")

Each record carries:

FieldWhat it tells you
atWhen (RFC 3339 timestamp).
actorWho — the asserted principal (e.g. user:alice), or key:<prefix> for requests that asserted none.
actionWhat happened: read (a by-id read) · search_hit (a document delivered in search results) · denied (a read the reader list refused) · admin_bypass (an admin-role key read past a reader list).
resourceThe document id (or the query id, for search_hit events).
outcomeHow it ended.

Filters (actor, resource, action, since, until) compose with AND, so "every document Alice read in March" or "every denial against this document, ever" is one call. Access records share their envelope with the signed provenance trail — lineage tells you what happened to a document; the access log tells you who saw it.

Capture is enabled per workspace

Access-audit capture is switched on per workspace. If your log comes back empty even after principal-scoped reads, capture isn't enabled for your workspace yet — contact support@aetherdb.ai to turn it on.


Pin a key to a principal

Everything above trusts your API key to assert the right principal. When a key leaves your trusted backend — a per-user worker, a device, a narrowly scoped integration — pin it. In the Dashboard, a key can be pinned to a single principal: reads through a pinned key act as that principal automatically (no as_principal needed), and asserting a different principal fails every call with a typed error. The pin can never be widened or overridden per request — if the key leaks, it can still only ever read as its one principal, and the audit log attributes everything it did.

from aether import AetherClient, PrincipalPinMismatchError

client = AetherClient(api_key=os.environ["AETHER_ALICE_KEY"])  # pinned to user:alice

hits = client.retrieve("expense policy", k=5)  # acts as user:alice automatically

try:
    client.as_principal("user:mallory").search("anything")
except PrincipalPinMismatchError:
    pass  # a pinned key cannot assert anyone else — fix the code, don't retry

The mismatch is a programming error, not a transient failure — don't retry it; fix the assertion (or use an unpinned key from your backend).


What this doesn't do

Honest limits to design around:

  • Aether does not authenticate your end users. The acting principal is an assertion your app makes, trusted from your API key. Authenticate users in your app, keep workspace keys server-side, and pin any key that leaves your trusted backend.
  • Group membership is yours to resolve. A group is a label, not a managed object — there's no group-membership API. Your app decides which groups a user acts with at the moment it scopes the handle, from its own directory or roles table.
  • Reader lists are not a tenant boundary. They control per-user visibility within a scope. To keep end-clients apart, use a partition per end-client — then add reader lists inside it. The two compose on one handle.
  • Admin-role keys bypass reader lists. That's the operational escape hatch, and it's visible: every bypass lands in the audit log as admin_bypass. Treat admin keys accordingly.

Next steps