USP · Phase 8 + 11

RBAC + audit chain

Permissions enforced where the data lives, not where the app code happens to remember to check. Every write recorded in a tamper-evident chain.

Why storage-layer?

Most memory stacks enforce permissions at the API layer: your app code checks who the caller is and decides which graph to query. That works until someone misses a check — bug, race condition, refactor — and one user's memory leaks into someone else's query.

MemHQ pushes the check into Postgres. Every read passes through a predicate that intersects the caller's principal set with graph_acls; rows you don't have access to are invisible to the query planner. App bugs can't override the storage layer.

graph_acls table

The schema is intentionally boring:

CREATE TABLE graph_acls (
  id           text PRIMARY KEY,
  project_id   text NOT NULL,
  graph_id     text NOT NULL,         -- the user or group graph
  principal_id text NOT NULL,         -- whose access is granted
  principal_type text NOT NULL,       -- user | okta_group | api_key
  role         text NOT NULL,         -- reader | writer | admin
  created_at   timestamptz DEFAULT now()
);

Principal types

  • user — a single end-user inside the project
  • okta_group — claims-mapped group from your IdP (Phase 10)
  • api_key — a scoped, non-root API key

Roles

  • reader — search + read
  • writer — reader plus ingest + supersession
  • admin — writer plus ACL management on this graph
POST/v1/graphs/:graphId/acls

Grant access. Idempotent on the natural key (graph, principal, role).

// Request
{
  "principalId":   "user_clerk_xyz",
  "principalType": "user",
  "role":          "reader"
}

// Response (201)
{
  "id": "acl_01HXY...",
  "graphId": "grph_...",
  "principalId": "user_clerk_xyz",
  "principalType": "user",
  "role": "reader",
  "createdAt": "..."
}
GET/v1/graphs/:graphId/acls

List ACL grants on a graph.

DELETE/v1/graphs/:graphId/acls/:id

Revoke an ACL grant. Audit-logged.

Plan availability

ACL grants are available on Pro and above. The default project key always has admin access to every graph it owns — ACLs apply when you provision scoped keys or wire end-user auth through.

Audit hash chain

Every state-changing action — memory create, supersession, ACL grant / revoke, ontology bump, user delete — appends a row to the audit log with a SHA-256 hash that includes the previous row's hash. The chain is append-only and verifiable end-to-end.

audit_log columns
─────────────────
  id              ULID, sortable by time
  project_id      tenant boundary
  graph_id        nullable — some actions span graphs
  principal_id    who performed the action
  action          e.g. "memory.create", "acl.revoke"
  resource_type   "memory" | "user" | "graph" | "acl" | ...
  resource_id
  before          jsonb snapshot pre-change
  after           jsonb snapshot post-change
  prev_hash       sha256 of the previous row in this project
  hash            sha256(prev_hash || canonical_json(this_row))
  created_at
GET/v1/audit

List recent audit entries. Filter by resourceType, action, principalId.

{
  "entries": [
    {
      "id": "aud_01HXY...",
      "action": "memory.create",
      "resourceType": "memory",
      "resourceId": "mem_...",
      "principalId": "usr_...",
      "createdAt": "...",
      "hash": "9f4c...",
      "prevHash": "8a2b..."
    }
  ]
}
POST/v1/audit/verify

Recompute every hash in the project's chain and report mismatches. Good for periodic integrity checks; you'd typically run this as a cron from your monitoring stack.

{
  "verified": true,
  "checkedRows": 4127,
  "firstMismatchAt": null,
  "tookMs": 312
}

A failed verify returns:

{
  "verified": false,
  "checkedRows": 4127,
  "firstMismatchAt": "aud_01HXX...",
  "mismatchKind": "hash"   // or "prev_hash_pointer"
}

Threat model

  • Tamper detection, not prevention. A DB admin can still write directly to the table. The hash chain ensures the tamper is visible the next time anyone runs verify.
  • Off-machine pinning is available on Enterprise: an optional weekly pin of the chain head to a customer-controlled object store (S3 / GCS / Azure Blob) so a malicious admin would also need write access there.
  • Soft-delete is audit-visible. "Delete a memory" writes both a memory.update with isActive: false and a follow-on audit row — never silent.