Permission Model
Access control in SAVVI Studio is policy-driven and graph-native. Permissions are not stored on edge metadata or in a separate ACL table — they are materialized from predicate policies whenever statements are created in the graph.
Bitmask Encoding
Permission levels use a SMALLINT bitmask where each higher level includes all lower bits:
| Level | Bits | Binary | Includes |
|---|---|---|---|
| read | 1 | 0001 | read |
| write | 3 | 0011 | read, write |
| admin | 7 | 0111 | read, write, admin |
| grant | 15 | 1111 | read, write, admin, grant |
Comparison is currentBits >= requiredBits. A user with admin (7) automatically passes read (1) and write (3) checks.
TypeScript Constants
Permission constants are defined once in @savvi-studio/graph-model and re-exported by downstream packages:
import { PermissionLevel, PERMISSION_BITS, hasPermissionLevel } from '@savvi-studio/graph-model';
// Enum for typed bitmask operations
PermissionLevel.Read // 1
PermissionLevel.Write // 3
PermissionLevel.Admin // 7
PermissionLevel.Grant // 15
// Record mapping string labels to bitmask values
PERMISSION_BITS.read // 1
PERMISSION_BITS.write // 3
PERMISSION_BITS.admin // 7
PERMISSION_BITS.owner // 15
// Comparison helper
hasPermissionLevel(PermissionLevel.Admin, PermissionLevel.Read) // true
// Convenience helpers
import { canRead, canWrite, canAdmin, canGrant } from '@savvi-studio/graph-model';
Predicate Policies
A predicate policy is a rule that says: "When a statement is created whose predicate's kind matches this pattern, grant a specified permission level on the statement's object to the statement's subject."
Policy Table
CREATE TABLE graph.predicate_policy (
id BIGSERIAL PRIMARY KEY,
predicate LTREE NOT NULL, -- ltree pattern to match
permission_bits SMALLINT NOT NULL, -- 1, 3, 7, or 15
resource_kind LTREE, -- optional scope restriction
resource_id TEXT, -- optional specific resource
is_active BOOLEAN NOT NULL DEFAULT TRUE,
priority INTEGER NOT NULL DEFAULT 0,
CHECK (permission_bits IN (1, 3, 7, 15)),
CHECK (priority >= 0 AND priority <= 100)
);
How Policies are Evaluated
When a statement (subject, predicate, object) is created:
- Look up the predicate resource's
kind(e.g.,teams.owner). - Find all active predicate policies where
predicatematches via ltree subtree containment. - If
resource_kindis set on a policy, also require the object resource'skindto match. - The highest-priority matching policy's
permission_bitsvalue is granted. - A row is inserted into
graph.permission_cache.
Module-Installed Policies
Predicate policies are typically defined in module manifests (.aion files):
type: predicate_policy
name: team_owner_admin
predicate: teams.owner
permissionLevel: admin
resourceKind: teams
priority: 90
When a module is installed, resources with kind: 'graph.predicate_policy' trigger a sync function (graph.sync_predicate_policy_from_resource) that copies the policy data into the graph.predicate_policy table.
Permission Cache
Computed permissions are stored in a materialized cache for fast lookups:
CREATE TABLE graph.permission_cache (
id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL,
subject_kind LTREE NOT NULL,
resource_id BIGINT NOT NULL,
resource_kind LTREE NOT NULL,
permission_bits SMALLINT NOT NULL,
derived_from JSONB, -- provenance metadata
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_valid BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT permission_cache_unique
UNIQUE (subject_id, subject_kind, resource_id, resource_kind)
);
Cache Invalidation
Two triggers maintain cache consistency:
- Statement trigger — On INSERT, UPDATE, or DELETE of a statement, invalidates cache entries where
subject_idorresource_idis involved. - Policy trigger — On INSERT, UPDATE, or DELETE of a predicate policy, invalidates all cache entries that could be affected by the policy change.
Invalidation sets is_valid = FALSE rather than deleting rows. On the next permission check, the cache miss causes recomputation.
SQL Functions
The permission system provides these core functions:
| Function | Purpose |
|---|---|
graph.compute_permission(subject, resource) |
Traverse statements and policies to compute permission level |
graph.has_permission(subject, resource, required) |
Check cache first, compute if stale, return boolean |
graph.check_access(subject, resource, required) |
Like has_permission but raises exception on denial |
graph.get_permission_bits(subject, resource) |
Return the raw bitmask value |
graph.can_read(subject, resource) |
Convenience: has_permission(..., 1) |
graph.can_write(subject, resource) |
Convenience: has_permission(..., 3) |
graph.can_admin(subject, resource) |
Convenience: has_permission(..., 7) |
graph.can_grant(subject, resource) |
Convenience: has_permission(..., 15) |
Query Layer
The graph client provides a typed permission API with scoping:
const permissions = graphClient.permissions();
// Fully-bound check
const checker = permissions.forSubject(userId).forResource(resourceId, resourceKind);
await checker.canRead(); // boolean
await checker.canWrite(); // boolean
await checker.canAdmin(); // boolean
// Direct check
await permissions.check(subjectId, subjectKind, resourceId, resourceKind, 'admin');
See Also
- Graph Architecture — System overview
- Resource Model — Resources that permissions protect
- Statement Model — Statements that trigger permission grants
- Module System — How modules install predicate policies