This document covers Role-Based Access Control (RBAC) implementation using SurrealDB PERMISSIONS clauses.
SurrealDB PERMISSIONS are evaluated at query time using the $auth or $token variable, populated from the JWT. This enables database-level enforcement of access control, not just application-level filtering.
-- When a user queries, SurrealDB automatically filters:
SELECT * FROM activity_template
-- Becomes (internally):
SELECT * FROM activity_template WHERE org_id = $token.org_idBoth kinds of token that reach SurrealDB verify against one access method, apikey_token (TYPE JWT, ALGORITHM HS512), defined in repos/activity-api/sql/000-auth-schema.surql. Its key is substituted from the shared JWT secret when the schema is applied, so the signers and the verifier hold one secret (see SCHEMA_OWNERSHIP.md). The $auth vs $token split comes from whether the token carries an id claim that resolves to a real SurrealDB record:
| Caller | Token signed by | $auth |
$token |
|---|---|---|---|
| A user session | identity-vessel | NONE — the token carries no id claim |
Populated (raw claims) |
| An API-key request | activity-api, after identity-vessel has validated the key: a short-lived database token for activity-api's own session | NONE — the signer omits id on purpose, because api_key:<keyId> is not a record |
Populated (raw claims) |
Rule of thumb: use $token in PERMISSIONS. Neither signer issues an id claim, so a clause written on $auth.* evaluates against NONE and silently filters everything out — the symptom is empty template listings, empty Thompson queries and empty impulse resolution under an otherwise valid credential.
$auth is correct only for a token whose id claim names a real record, and only when a pattern genuinely needs that record (a display name, a preference). For org scoping, use $token.org_id.
After authentication, $token contains the claims from the JWT:
{
"sub": "users:alice", // User or instance ID
"org_id": "organizations:acme", // Organization
"role": "admin", // User's role
"project_id": "projects:api", // Optional: project scope
"scopes": ["read", "write"] // Optional: API key scopes
}Note: The examples below use
$auth.*for historical continuity only. In deployed activity-api migrations these are$token.*— see the$authvs$tokensection above. When authoring new migrations, always use$token.*for org/project scoping. Reach for$auth.*only when you genuinely need the authenticated record, under a token whoseidclaim names one; neither in-fleet signer issues such a claim. Using$auth.org_idin new migrations will silently filter out every caller.
Most common pattern - users can only access their organization's data:
DEFINE TABLE activity_template SCHEMAFULL
PERMISSIONS
FOR select, create, update, delete WHERE org_id = $auth.org_id;For shared resources like public templates:
DEFINE TABLE activity_template SCHEMAFULL
PERMISSIONS
-- Can read own org's templates OR public templates
FOR select WHERE org_id = $auth.org_id OR public = true
-- Can only create in own org
FOR create WHERE org_id = $auth.org_id
-- Can only modify own org's templates
FOR update, delete WHERE org_id = $auth.org_id;For sensitive operations:
DEFINE TABLE organizations SCHEMAFULL
PERMISSIONS
FOR select WHERE id = $auth.org_id
FOR update WHERE id = $auth.org_id AND $auth.role = 'admin'
FOR delete WHERE false; -- Never allow delete via APIFor user-owned resources like annotations:
DEFINE TABLE annotations SCHEMAFULL
PERMISSIONS
-- Anyone in org can read
FOR select WHERE org_id = $auth.org_id
-- Owner or admin can modify
FOR update, delete WHERE org_id = $auth.org_id
AND (user_id = $auth.id OR $auth.role = 'admin')
-- Anyone in org can create
FOR create WHERE org_id = $auth.org_id;For project-level isolation within an org:
DEFINE TABLE execution_traces SCHEMAFULL
PERMISSIONS
-- Project members only (or org admins)
FOR select WHERE org_id = $auth.org_id
AND (project_id = $auth.project_id OR $auth.role = 'admin')
FOR create WHERE org_id = $auth.org_id
AND project_id = $auth.project_id;For scope-based visibility:
DEFINE TABLE activity_template SCHEMAFULL
PERMISSIONS
FOR select WHERE
-- Global templates visible to all
scope = 'global' OR
-- Org templates visible to org members
(scope = 'org' AND org_id = $auth.org_id) OR
-- Project templates visible to project members
(scope = 'project' AND project_id = $auth.project_id);Use VALUE clauses to auto-populate org_id from $auth:
DEFINE FIELD org_id ON activity_template TYPE record<organizations>
ASSERT $value != NONE
VALUE $value OR $auth.org_id;This ensures:
- If org_id is provided, use it (validated by PERMISSIONS)
- If not provided, use $auth.org_id (current user's org)
Wrong:
// Only filtering in application code - can be bypassed!
const templates = await db.query(`
SELECT * FROM activity_template WHERE org_id = $org_id
`, { org_id: userOrgId });Right:
// Use authenticated connection - PERMISSIONS enforced
const db = await createAuthenticatedClient(jwtToken);
const templates = await db.query(`SELECT * FROM activity_template`);
// SurrealDB automatically filters by $token.org_idWrong:
DEFINE TABLE secrets SCHEMAFULL;
-- No PERMISSIONS = accessible to everyone!Right:
DEFINE TABLE secrets SCHEMAFULL
PERMISSIONS
FOR select, create, update, delete WHERE org_id = $auth.org_id;Wrong:
PERMISSIONS
FOR select WHERE org_id = $auth.org_id OR true; -- Always true!Right:
PERMISSIONS
FOR select WHERE org_id = $auth.org_id OR public = true;// User A creates a template
const userADb = await createAuthenticatedClient(userAToken);
await userADb.query(`CREATE activity_template SET name = 'A Template', org_id = $auth.org_id`);
// User B (different org) should not see it
const userBDb = await createAuthenticatedClient(userBToken);
const result = await userBDb.query(`SELECT * FROM activity_template WHERE name = 'A Template'`);
assert(result.length === 0); // Should be empty// Member cannot update org settings
const memberDb = await createAuthenticatedClient(memberToken);
try {
await memberDb.query(`UPDATE organizations SET name = 'Hacked'`);
assert.fail('Should have thrown');
} catch (e) {
// Expected: permission denied
}
// Admin can update
const adminDb = await createAuthenticatedClient(adminToken);
await adminDb.query(`UPDATE organizations SET name = 'New Name'`);
// Success| Role | Description | Typical Permissions |
|---|---|---|
admin |
Organization administrator | Full CRUD on org data |
member |
Regular user | Read all, write own resources |
viewer |
Read-only access | Read only |
-- Admin-only operations
FOR delete WHERE $auth.role = 'admin'
-- Member or above
FOR create WHERE $auth.role IN ['admin', 'member']
-- Any authenticated user
FOR select WHERE org_id = $auth.org_idAPI keys can have restricted scopes:
DEFINE FIELD scopes ON api_keys TYPE array<string>
DEFAULT ['read'];Check scopes in PERMISSIONS:
PERMISSIONS
FOR select WHERE org_id = $auth.org_id
FOR create WHERE org_id = $auth.org_id AND 'write' IN $auth.scopes;Always create indexes on org_id for filtered tables:
DEFINE INDEX idx_template_org ON activity_template FIELDS org_id;
DEFINE INDEX idx_template_org_project ON activity_template FIELDS org_id, project_id;Efficient (uses index):
SELECT * FROM activity_template WHERE org_id = $auth.org_id LIMIT 100Potentially slow (full scan):
SELECT * FROM activity_template WHERE name CONTAINS 'test'
-- Add: AND org_id = $auth.org_id to use index firstSELECT * FROM $token;EXPLAIN SELECT * FROM activity_template;-- Create test user with specific claims
DEFINE ACCESS test_user ON DATABASE TYPE JWT
ALGORITHM HS256 KEY 'test-key'
DURATION FOR TOKEN 1h;
-- Generate token with claims
-- Then test queries- Always use PERMISSIONS on multi-tenant tables
- Never rely only on application-level filtering
- Use
$token.org_idas the primary isolation mechanism (works for every in-fleet token;$auth.org_idisNONEunder both signers and silently drops results) - Create indexes on org_id fields
- Test isolation between organizations
- Use authenticated connections (
createAuthenticatedClient)