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 projectokta_group— claims-mapped group from your IdP (Phase 10)api_key— a scoped, non-root API key
Roles
reader— search + readwriter— reader plus ingest + supersessionadmin— writer plus ACL management on this graph
/v1/graphs/:graphId/aclsGrant 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": "..."
}/v1/graphs/:graphId/aclsList ACL grants on a graph.
/v1/graphs/:graphId/acls/:idRevoke an ACL grant. Audit-logged.
Plan availability
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
/v1/auditList 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..."
}
]
}/v1/audit/verifyRecompute 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.updatewithisActive: falseand a follow-on audit row — never silent.