Savvi Studio

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:

  1. Look up the predicate resource's kind (e.g., teams.owner).
  2. Find all active predicate policies where predicate matches via ltree subtree containment.
  3. If resource_kind is set on a policy, also require the object resource's kind to match.
  4. The highest-priority matching policy's permission_bits value is granted.
  5. 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:

  1. Statement trigger — On INSERT, UPDATE, or DELETE of a statement, invalidates cache entries where subject_id or resource_id is involved.
  2. 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