
Constructive Access Control
- 2 installs
- Updated August 4, 2026
- constructive-io/constructive-skills
Access control with roles, permissions, profiles, grants, and entity-scoped authorization.
About
Constructive Access Control The access control model shows how users get permissions, how roles and profiles organize them.. Covers the semantic layer: what access means in a Constructive app.
- Defining what permissions users should have in an app
- Creating custom roles via profiles (Editor, Viewer, Manager, etc.)
Constructive Access Control by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,788 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/constructive-io/constructive-skills --skill constructive-access-controlAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | August 4, 2026 |
| Repository | constructive-io/constructive-skills ↗ |
What it does
Access control with roles, permissions, profiles, grants, and entity-scoped authorization.
Files
Constructive Access Control
The access control model — how users get permissions, how roles and profiles organize them, and how access composes across scopes. This skill covers the semantic layer: what access means in a Constructive app, how to configure it via blueprints and the ORM, and how the different layers (roles, profiles, grants, defaults) compose into effective permissions.
When to Apply
Use this skill when:
- Defining what permissions users should have in an app
- Creating custom roles via profiles (Editor, Viewer, Manager, etc.)
- Configuring which permissions new members receive automatically
- Understanding how admin/owner/member roles differ
- Granting or revoking permissions for individual members
- Setting up entity-scoped access (app vs org vs custom entity)
- Assigning profiles to memberships via invites or direct assignment
- Understanding effective permission resolution (grants + profiles)
Relationship to Other Skills
| Skill | Focus | This skill covers |
|---|---|---|
| `constructive-security` | Enforcement — Authz* policies, RLS, how access is enforced at the database level | Model — what access exists, who gets it, how it composes |
| `constructive-entities` | Structure — entity types, multi-tenancy, provisioning | Access within structure — how permissions scope to entities |
| `constructive-auth` | Identity — login, sessions, MFA, devices | Authorization — what authenticated users can do |
Access Control Layers
A Constructive app has four composable access layers:
┌─────────────────────────────────────────────┐
│ 1. Role (admin / owner / member) │ ← built-in, highest precedence
├─────────────────────────────────────────────┤
│ 2. Profile (named permission bundle) │ ← reusable role definitions
├─────────────────────────────────────────────┤
│ 3. Direct Grants (per-member overrides) │ ← individual adjustments
├─────────────────────────────────────────────┤
│ 4. Permission Defaults (module-level base) │ ← automatic on join
└─────────────────────────────────────────────┘Effective permissions = Role bypass OR (Profile permissions ∪ Direct grants ∪ Defaults)
Quick Reference
Enabling Access Control in Blueprints
{
"entity_types": [
{
"name": "Organization",
"prefix": "org",
"hasProfiles": true
}
]
}Every entity type automatically gets a permissions_module and memberships_module. Setting hasProfiles: true additionally provisions the profiles system for that scope.
ORM Tables by Scope
| Scope | Permissions | Grants | Profiles | Memberships | Defaults |
|---|---|---|---|---|---|
| App | appPermission | appGrant | appProfile | appMembership | appPermissionDefault |
| Org | orgPermission | orgGrant | orgProfile | orgMembership | orgPermissionDefault |
| Custom | {prefix}Permission | {prefix}Grant | {prefix}Profile | {prefix}Membership | {prefix}PermissionDefault |
References
| File | Content |
|---|---|
| admin-owner-member.md | Admin, owner, and member role semantics — grant tables, promotion/demotion, audit trail |
| roles-hierarchy.md | Org hierarchy — chart edges, closure table traversal, AuthzOrgHierarchy policy, direction/depth |
| named-permissions.md | Named permission slots, module registration, discovering available permissions |
| profiles.md | Profile definitions, permission bundles, default profiles, system profiles |
| permission-defaults.md | Automatic permissions for new members, module defaults, overriding |
| entity-scoped-access.md | App vs org vs custom entity scope, permission isolation, cross-scope patterns |
| grants-lifecycle.md | Granting/revoking permissions, effective permission computation, audit trail |
| membership-access.md | Membership creation, invite-time assignment, state transitions, approval |
Cross-References
- Enforcement details: `constructive-security` — how permissions translate into RLS policies
- Entity provisioning: `constructive-entities` — creating entity types that carry permissions
- Invite system: `constructive-entities` → invites.md — profile assignment on invite
- Read-only access: `constructive-security` → read-only-access.md —
isReadOnlymembership field and read-only API keys - Billing/limits: `constructive-billing` — quota enforcement (separate from permission enforcement)
Admin, Owner & Member
Every membership in a Constructive app has a role — a built-in access level that determines base capabilities. Roles are orthogonal to permissions: they control structural privileges (who can manage the entity) while permissions control feature access (what actions are allowed).
Built-in Roles
| Role | Field | Description |
|---|---|---|
| Owner | isOwner: true | Creator of the entity. Full control, cannot be removed by admins. One owner per entity (transferable). |
| Admin | isAdmin: true | Elevated management access. Can manage members, permissions, profiles. Multiple admins allowed. |
| Member | (default) | Standard access. Governed by permissions (direct grants + profile). |
Role Precedence
Owner > Admin > Member- Owners bypass all permission checks — they always have full access to all features within their entity.
- Admins bypass all permission checks — they receive all named permissions implicitly, regardless of grants or profile.
- Members are governed by the permission system — their effective access is determined by their profile + direct grants + defaults.
Key Difference: Owner vs Admin
| Capability | Owner | Admin |
|---|---|---|
| All named permissions | Yes | Yes |
| Manage other admins | Yes | No |
| Transfer ownership | Yes | No |
| Remove other admins | Yes | No |
| Be removed by another admin | No | Yes |
| Multiple per entity | No | Yes |
Reading Roles (ORM)
// Check a member's role
const membership = await db.appMembership.findOne({
where: { actorId: { equalTo: userId } },
select: {
id: true,
isAdmin: true,
isOwner: true,
permissions: true,
granted: true,
profileId: true
}
}).execute();
// Org-scope equivalent
const orgMembership = await db.orgMembership.findOne({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgId } },
select: {
id: true,
isAdmin: true,
isOwner: true,
permissions: true,
granted: true,
profileId: true
}
}).execute();Promoting to Admin
Admin promotion uses the admin grants table — an append-only audit log. Inserting a record with isGrant: true triggers an automatic update to the membership's isAdmin field. Direct column updates to isAdmin are blocked by column grants.
Only existing admins (at entity scope) or owners can create admin grants:
// Promote a member to admin (org scope)
await db.orgAdminGrant.create({
data: {
isGrant: true,
actorId: userId,
entityId: orgId,
grantorId: currentUserId
},
select: { id: true }
}).execute();
// App scope (no entityId needed)
await db.appAdminGrant.create({
data: {
isGrant: true,
actorId: userId,
grantorId: currentUserId
},
select: { id: true }
}).execute();# CLI equivalent
constructive admin:org-admin-grant create \
--data.isGrant true \
--data.actorId $USER_ID \
--data.entityId $ORG_ID \
--data.grantorId $CURRENT_USER_IDDemotion
Revoking admin is the same table — insert with isGrant: false:
// Revoke admin role (org scope)
await db.orgAdminGrant.create({
data: {
isGrant: false,
actorId: userId,
entityId: orgId,
grantorId: currentUserId
},
select: { id: true }
}).execute();The trigger automatically sets isAdmin = false on the membership (unless the user is also an owner).
Audit Trail
Every admin grant/revoke is a permanent record — the table is append-only with timestamps and the grantorId of who made the change. You can query the full history:
const history = await db.orgAdminGrant.findMany({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgId } },
select: { id: true, isGrant: true, grantorId: true, createdAt: true },
orderBy: ['CREATED_AT_ASC']
}).execute();Transferring Ownership
Ownership transfer uses the owner grants table — same pattern as admin grants. Only the current owner can create owner grants:
// Transfer ownership (org scope)
// Step 1: Revoke current owner
await db.orgOwnerGrant.create({
data: {
isGrant: false,
actorId: currentOwnerId,
entityId: orgId,
grantorId: currentOwnerId
},
select: { id: true }
}).execute();
// Step 2: Grant ownership to new user
await db.orgOwnerGrant.create({
data: {
isGrant: true,
actorId: newOwnerId,
entityId: orgId,
grantorId: currentOwnerId
},
select: { id: true }
}).execute();Grant Tables by Scope
| Scope | Admin Grants | Owner Grants | RLS Policy |
|---|---|---|---|
| App | db.appAdminGrant | db.appOwnerGrant | AuthzAppMembership { is_admin: true } / { is_owner: true } |
| Org | db.orgAdminGrant | db.orgOwnerGrant | AuthzEntityMembership { is_admin: true } / { is_owner: true } |
| Custom | db.{prefix}AdminGrant | db.{prefix}OwnerGrant | Same pattern, scoped to membership type |
Who Can Create Grants
| Grant Type | Who Can Insert | RLS Rule |
|---|---|---|
| App admin grant | App admins or owners | AuthzAppMembership { is_admin: true } |
| App owner grant | App owners only | AuthzAppMembership { is_owner: true } |
| Org admin grant | Entity admins within that org | AuthzEntityMembership { is_admin: true } |
| Org owner grant | Entity owners within that org | AuthzEntityMembership { is_owner: true } |
Role Semantics by Scope
| Scope | Owner | Admin | Member |
|---|---|---|---|
| App | App creator (bootstrap user) | App-wide administrators | Regular app users |
| Org | Organization creator | Organization administrators | Organization members |
| Custom (channel, team, etc.) | Entity creator | Entity managers | Entity participants |
Blueprint: Initial Roles
When bootstrapping a database, the first user is created as both owner and admin:
// Bootstrap the first user (from constructive-auth)
await db.query.signUp({
input: {
targetDatabaseId: dbId,
password: 'initial-password',
isAdmin: true,
isOwner: true
}
}).execute();Subsequent users join as regular members (via sign-up or invite) and are promoted via the admin grants table as needed.
Admin-Only Actions
Actions restricted to admins (and owners) include:
- Managing other members' permissions (granting/revoking)
- Assigning profiles to members
- Creating and editing profile definitions
- Viewing all members and their permission state
- Managing entity settings (membership defaults, invite modes)
- Accessing admin-only permissions (e.g.,
manage_agents,manage_storage)
When Roles Don't Apply
Roles apply to memberships (actor ↔ entity relationships). For tables secured with non-membership policies (e.g., AuthzDirectOwner for personal data), there's no role hierarchy — just ownership of the row.
Entity-Scoped Access
Permissions are scoped to entities — each membership scope (app, org, custom) has its own independent permission space. A user's permissions in one organization don't carry to another, and app-level permissions don't imply org-level permissions.
Scope Hierarchy
App (global scope)
└── Org (per-organization scope)
├── Channel (per-channel scope)
├── Team (per-team scope)
└── Department (per-department scope)Each level is an independent permission space with its own:
- Named permissions registry
- Grants log
- Permission defaults
- Profiles (if enabled)
- Memberships
How Scoping Works
| Scope | ORM Tables | Membership | Permissions | Grants |
|---|---|---|---|---|
| App | appMembership, appPermission, appGrant, appProfile | One per user | App-wide features | App-wide |
| Org | orgMembership, orgPermission, orgGrant, orgProfile | One per user per org | Org-specific features | Per-org |
| Custom | {prefix}Membership, {prefix}Permission, {prefix}Grant, {prefix}Profile | One per user per entity | Entity-specific features | Per-entity |
Example: User in Multiple Orgs
// User has different permissions in different orgs
const orgAMembership = await db.orgMembership.findOne({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgAId } },
select: { permissions: true, isAdmin: true, profileId: true }
}).execute();
// → { permissions: 'invoke_agents,write_files', isAdmin: false, profileId: editorProfileId }
const orgBMembership = await db.orgMembership.findOne({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgBId } },
select: { permissions: true, isAdmin: true, profileId: true }
}).execute();
// → { permissions: 'manage_agents,manage_storage', isAdmin: true, profileId: null }Permission Isolation
Permissions do NOT inherit across scopes:
| Scenario | Result |
|---|---|
| User is app admin | Does NOT automatically have org admin |
User has manage_agents in Org A | Does NOT have it in Org B |
| User is org admin | Does NOT automatically have channel admin |
| User has a profile in one org | That profile doesn't exist in another org |
Each scope is a fully independent permission system. Cross-scope access requires separate memberships.
Blueprint: Multi-Scope Entity Types
{
"entity_types": [
{
"name": "App",
"prefix": "app",
"hasProfiles": true
},
{
"name": "Organization",
"prefix": "org",
"hasProfiles": true
},
{
"name": "Channel",
"prefix": "channel",
"parentEntity": "org",
"hasProfiles": false
}
]
}What Gets Created Per Scope
When an entity type is provisioned with access control:
1. Permissions module — automatically installed; registers scope's permission table 2. Memberships module — tracks who belongs to each entity instance 3. Profiles module (optional) — enabled via hasProfiles: true 4. Invites module (optional) — enabled via hasInvites: true
Cross-Scope Patterns
Pattern: App-Level Gates for Global Features
Use app-scope permissions to gate features that span all organizations:
// App-level permission for platform admin features
const appMembership = await db.appMembership.findOne({
where: { actorId: { equalTo: userId } },
select: { isAdmin: true, permissions: true }
}).execute();Pattern: Org-Level Gates for Org Features
Use org-scope permissions to gate features within an organization:
// Org-level permission check before allowing an action
const orgMembership = await db.orgMembership.findOne({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgId } },
select: { permissions: true }
}).execute();Pattern: Nested Entity Access
For child entities (channels under orgs), the parent entity's membership is typically required for access:
{
"policies": [
{
"$type": "AuthzEntityMembership",
"data": {
"entity_field": "entity_id",
"membership_type": 3,
"permission": "invoke_agents"
}
}
]
}The "Users Are Organizations" Pattern
Every user has a personal org identity — their own org with a single-member membership (themselves as owner). This means:
- Personal data can use
AuthzEntityMembershipwith the user's personal org - The same RLS policies work for both personal and shared data
- Transitioning personal data to shared (org-owned) data doesn't require policy changes
User "Alice"
├── App membership (type=1) → app-level permissions
├── Personal org membership (type=2, entity=alice_org) → personal data
├── Company org membership (type=2, entity=company_org) → company data
└── Project channel membership (type=3, entity=project_channel) → channel dataManaging Entity-Specific Defaults
Each entity can customize its own permission defaults independently:
// Org A: new members get invoke_agents + write_files
await db.orgPermissionDefault.create({
data: {
entityId: orgAId,
permissions: orgADefaultValue
},
select: { id: true }
}).execute();
// Org B: new members get only invoke_agents (more restrictive)
await db.orgPermissionDefault.create({
data: {
entityId: orgBId,
permissions: orgBDefaultValue
},
select: { id: true }
}).execute();Key Behaviors
- Complete isolation — permissions in one entity are invisible to another
- Independent configuration — each entity configures its own defaults, profiles, and grants
- Parent doesn't imply child — being an org admin doesn't make you a channel admin
- Same permission names, different scopes —
invoke_agentsin Org A and Org B are separate grants - Users are orgs — personal ownership uses the same entity membership model as shared access
Grants Lifecycle
Grants are the mechanism for giving or removing permissions from individual members. Every permission change is recorded as an append-only audit event — grants are never modified in place, only appended.
Grant/Revoke Model
Grant event: { permissions: value, isGrant: true, actorId, grantorId }
Revoke event: { permissions: value, isGrant: false, actorId, grantorId }- Grant (
isGrant: true) — adds permissions to the member's direct grants - Revoke (
isGrant: false) — removes permissions from the member's direct grants - The membership's
grantedfield always reflects the current state after all events are applied
Granting Permissions
// Grant permissions to a member at app scope
await db.appGrant.create({
data: {
permissions: permissionValue,
isGrant: true,
actorId: memberId,
grantorId: adminId
},
select: { id: true }
}).execute();
// Grant permissions at org scope
await db.orgGrant.create({
data: {
permissions: permissionValue,
isGrant: true,
actorId: memberId,
entityId: orgId,
grantorId: adminId
},
select: { id: true }
}).execute();Building the Permission Value
// Resolve permission names to a value
const result = await db.query.orgPermissionsGetMaskByNames({
names: 'invoke_agents,write_files'
}).execute();
const permissionValue = result.permissions;Revoking Permissions
// Revoke permissions from a member
await db.orgGrant.create({
data: {
permissions: permissionValue,
isGrant: false,
actorId: memberId,
entityId: orgId,
grantorId: adminId
},
select: { id: true }
}).execute();Note: Revoking removes from direct grants only. If the member's profile also includes that permission, they still have it through their profile.
Effective Permissions
A member's effective permissions is the union of all permission sources:
effective = granted (direct) ∪ profile.permissions ∪ defaultsThe membership exposes both:
| Field | Meaning |
|---|---|
permissions | Effective permissions (the full resolved set) |
granted | Direct grants only (what was explicitly given to this member) |
const membership = await db.orgMembership.findOne({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgId } },
select: {
permissions: true, // effective (all sources)
granted: true, // direct grants only
profileId: true, // which profile is assigned
isAdmin: true,
isOwner: true
}
}).execute();Resolution Priority
1. Admin/Owner bypass — if isAdmin or isOwner, all permissions are granted (no further resolution needed) 2. Union of sources — for regular members: profile permissions ∪ direct grants ∪ defaults
There is no "deny" mechanism — permissions are purely additive. To remove access, you must revoke the grant AND remove it from the profile.
Viewing Grant History
The grants table is an append-only audit log:
// View all grant/revoke events for a member
const history = await db.orgGrant.findMany({
where: {
actorId: { equalTo: memberId },
entityId: { equalTo: orgId }
},
select: {
id: true,
permissions: true,
isGrant: true,
grantorId: true,
createdAt: true
},
orderBy: { createdAt: 'DESC' }
}).execute();Interpreting History
[
{ permissions: "invoke_agents,write_files", isGrant: true, grantorId: admin1, createdAt: "2024-01-01" },
{ permissions: "manage_agents", isGrant: true, grantorId: admin1, createdAt: "2024-02-01" },
{ permissions: "write_files", isGrant: false, grantorId: admin2, createdAt: "2024-03-01" },
]
// Current direct grants: invoke_agents + manage_agents (write_files was revoked)Audit Preservation
Grant records are preserved even when entities are deleted:
- If an organization is deleted, its grant records remain (entity reference is nullified)
- If a member is removed, their grant history is preserved
- This ensures compliance and audit trail integrity
Grantor Tracking
Every grant/revoke event records who made the change:
// Who granted this permission?
const grants = await db.orgGrant.findMany({
where: {
actorId: { equalTo: memberId },
isGrant: { equalTo: true }
},
select: {
grantorId: true,
permissions: true,
createdAt: true
}
}).execute();CLI Usage
# Grant permissions
constructive public:org-grant create \
--data.permissions "$PERMISSION_VALUE" \
--data.isGrant true \
--data.actorId $MEMBER_ID \
--data.entityId $ORG_ID \
--data.grantorId $ADMIN_ID
# Revoke permissions
constructive public:org-grant create \
--data.permissions "$PERMISSION_VALUE" \
--data.isGrant false \
--data.actorId $MEMBER_ID \
--data.entityId $ORG_ID \
--data.grantorId $ADMIN_IDKey Behaviors
- Append-only — grants are never modified or deleted; new events override previous state
- Additive model — no "deny"; permissions can only be added (granted) or removed (revoked)
- Profile-independent — revoking a direct grant doesn't affect profile-inherited permissions
- Audit trail — full history of who granted/revoked what and when
- Entity-preserved — grant records survive entity deletion for compliance
- Grantor accountability — every permission change traces back to the admin who made it
Membership & Access
Memberships are the link between a user (actor) and an entity (app, org, custom). Each membership carries role flags, a profile reference, direct grants, and state flags that determine what the member can access and do.
Membership Fields
| Field | Type | Description |
|---|---|---|
id | UUID | Membership record ID |
actorId | UUID | The user this membership belongs to |
entityId | UUID | The entity (org, channel, etc.) — null for app-scope |
isAdmin | boolean | Admin role flag |
isOwner | boolean | Owner role flag |
profileId | UUID? | Assigned profile (nullable) |
permissions | string | Effective permissions (resolved from all sources) |
granted | string | Direct grants only |
isReadOnly | boolean | Read-only flag — blocks all mutations when true (see read-only-access.md) |
isApproved | boolean | Whether the membership is active (waitlist gate) |
isVerified | boolean | Whether the member's identity is verified |
createdAt | timestamp | When the membership was created |
Membership Creation
Members join through several paths, each resulting in different initial access:
1. Sign-Up (App Membership)
When a user signs up, they get an app membership:
await db.query.signUp({
input: {
targetDatabaseId: dbId,
password: 'user-password'
}
}).execute();
// Creates app membership with:
// - isAdmin: false, isOwner: false
// - permissions: module defaults + permission defaults
// - profileId: default profile (if one exists)
// - isApproved: per app_membership_defaults setting2. Invite Claim (Any Scope)
When a user claims an invite:
await db.query.submitOrgInviteCode({
inviteCode: inviteUUID
}).execute();
// Creates org membership with:
// - isAdmin: false, isOwner: false
// - profileId: invite's profileId (if email invite with profile)
// - isApproved: true if sender has send_approved_invites, else per defaults
// - permissions: defaults + profile permissions (if profile assigned)3. Direct Creation (Admin Action)
Admins can create memberships directly:
await db.orgMembership.create({
data: {
actorId: userId,
entityId: orgId,
isApproved: true,
profileId: editorProfileId
},
select: { id: true }
}).execute();Membership States
Approval Gate
isApproved controls whether the membership is active:
isApproved | Effect |
|---|---|
true | Full access per permissions/role |
false | Waitlisted — RLS denies access to entity resources |
Configure the default for new members:
// Set membership defaults (new members auto-approved)
await db.orgMembershipDefault.create({
data: {
isApproved: true,
entityId: orgId
},
select: { id: true }
}).execute();
// Waitlist mode (new members must be approved)
await db.appMembershipDefault.create({
data: {
isApproved: false
},
select: { id: true }
}).execute();Approving a Waitlisted Member
await db.orgMembership.update({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgId } },
data: { isApproved: true }
}).execute();Verification Gate
isVerified tracks identity verification status:
isVerified | Effect |
|---|---|
true | Full access per permissions/role |
false | May have restricted access (depends on app's RLS configuration) |
Email invites auto-verify the user's email on claim. Other flows may require explicit verification.
Reading Memberships
// List all members of an org with their access state
const members = await db.orgMembership.findMany({
where: { entityId: { equalTo: orgId } },
select: {
id: true,
actorId: true,
isAdmin: true,
isOwner: true,
profileId: true,
permissions: true,
granted: true,
isApproved: true,
isVerified: true,
createdAt: true
}
}).execute();Filter by Role
// Find all admins in an org
const admins = await db.orgMembership.findMany({
where: {
entityId: { equalTo: orgId },
isAdmin: { equalTo: true }
},
select: { actorId: true }
}).execute();
// Find all members with a specific profile
const editors = await db.orgMembership.findMany({
where: {
entityId: { equalTo: orgId },
profileId: { equalTo: editorProfileId }
},
select: { actorId: true, permissions: true }
}).execute();Removing Members
// Remove a member from an org
await db.orgMembership.delete({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgId } }
}).execute();Grant history is preserved after removal — the audit trail remains intact.
Member Profiles (Display Info)
Separate from permission profiles, member profiles store display information:
// Create a member profile (display info)
await db.orgMemberProfile.create({
data: {
membershipId: orgMembershipId,
entityId: orgId,
actorId: userId,
displayName: 'Jane Smith',
email: 'jane@example.com',
title: 'Engineering Lead',
bio: 'Full-stack developer',
profilePicture: avatarUrl
},
select: { id: true }
}).execute();Note: "Member profiles" (display info) are distinct from "profiles" (permission bundles). They serve different purposes — one is for UI/directory, the other is for access control.
Membership Settings
Per-entity configuration for membership behavior:
// Configure org membership settings
await db.orgMembershipSetting.update({
where: { entityId: { equalTo: orgId } },
data: {
inviteProfileAssignmentMode: 'strict' // strict | permission_only | subset_only
}
}).execute();| Setting | Options | Description |
|---|---|---|
inviteProfileAssignmentMode | strict, permission_only, subset_only | Controls who can assign profiles via invites |
CLI Usage
# List members of an org
constructive public:org-membership find-many \
--where.entityId $ORG_ID \
--select id,actorId,isAdmin,permissions
# Approve a waitlisted member
constructive public:org-membership update \
--where.actorId $USER_ID \
--where.entityId $ORG_ID \
--data.isApproved true
# Remove a member
constructive public:org-membership delete \
--where.actorId $USER_ID \
--where.entityId $ORG_IDKey Behaviors
- One membership per user per entity — a user can only have one membership in a given org/entity
- Multiple memberships across entities — a user can be a member of many orgs simultaneously
- Approval required for access —
isApproved: falseeffectively blocks all access via RLS - Profile assignment at any time — profiles can be assigned at creation (via invite) or changed later
- State is mutable — roles, profiles, and approval status can be changed after creation
- Deletion preserves audit — removing a member doesn't destroy grant history
- Owner protection — owners cannot be removed by admins; ownership must be transferred first
Named Permissions
Permissions are named access rights that control what actions a member can perform within a scope. Each module registers its own permissions when installed, and they compose into a unified permission model per entity type.
How Permissions Work
1. Modules register permissions — when a module is installed (via blueprint or entityTypeProvision), it registers named permissions in the scope's permissions table 2. Members receive permissions — via defaults (automatic), profiles (bundled), or direct grants (individual) 3. Enforcement — RLS policies and application logic check whether the current user has the required permission before allowing an action
Discovering Permissions
List All Registered Permissions
// App-scope permissions
const appPerms = await db.appPermission.findMany({
select: { id: true, name: true, description: true }
}).execute();
// Org-scope permissions
const orgPerms = await db.orgPermission.findMany({
where: { entityId: { equalTo: orgId } },
select: { id: true, name: true, description: true }
}).execute();
// Custom entity scope (e.g., channel)
const channelPerms = await db.channelPermission.findMany({
where: { entityId: { equalTo: channelId } },
select: { id: true, name: true, description: true }
}).execute();Resolve Permission Names to Values
// Get a permission value from names (for use in grants/defaults)
const result = await db.query.appPermissionsGetMaskByNames({
names: 'invoke_agents,write_files'
}).execute();
// result.permissions → the permission value to use in grants
// Org-scope equivalent
const result = await db.query.orgPermissionsGetMaskByNames({
names: 'manage_agents,manage_storage'
}).execute();Resolve Values Back to Names
// Get permission names from a value (for display)
const result = await db.query.appPermissionsGetByMask({
mask: permissionValue
}).execute();
// result → array of permission name stringsModule-Registered Permissions
Each module declares its named permissions. These are automatically registered when the module is installed:
| Module | Member Permissions | Admin Permissions |
|---|---|---|
| Agent | invoke_agents | manage_agents |
| Function | invoke_functions | manage_functions |
| Graph | execute_graphs | manage_graphs |
| Storage | write_files, delete_files | manage_storage |
| Events | — | (all admin-only) |
| Billing | — | (all admin-only) |
| Hierarchy | — | (all admin-only) |
| Namespace | — | (all admin-only) |
| Notifications | — | (all admin-only) |
| Rate Limits | — | (all admin-only) |
| Usage | — | (all admin-only) |
Member permissions are granted to all members by default on join. Admin permissions require explicit grants or admin/owner role.
Invite-Related Permissions
The invite system registers additional permissions when installed:
| Permission | Description |
|---|---|
create_invites | Can create invites for other users |
admin_invites | Can view and manage all invites in the scope |
send_approved_invites | Invites from this user auto-approve the new membership |
assign_profiles | Can attach a profile to email invites |
Permission Categories
Permissions follow a naming convention:
| Pattern | Meaning | Example |
|---|---|---|
invoke_* | Use a feature | invoke_agents, invoke_functions |
manage_* | Administer a feature | manage_agents, manage_storage |
create_* | Create new entities | create_invites, create_entity |
admin_* | Administrative access | admin_invites, admin_members |
write_* / delete_* | Data operations | write_files, delete_files |
execute_* | Run operations | execute_graphs |
Custom Permissions via Blueprint
You can register custom permissions through the blueprint entity_types definition:
{
"entity_types": [
{
"name": "Organization",
"prefix": "org",
"modules": [
["permissions_module", { "scope": "org" }]
]
}
]
}Custom permissions can then be created via the ORM:
// Register a custom permission
await db.orgPermission.create({
data: {
name: 'approve_documents',
description: 'Can approve documents for publication',
entityId: orgId
},
select: { id: true }
}).execute();Permission Enforcement in RLS
When a policy requires a specific permission, it's declared in the blueprint:
{
"policies": [
{
"$type": "AuthzEntityMembership",
"data": {
"entity_field": "entity_id",
"membership_type": 2,
"permission": "manage_agents"
},
"privileges": ["update", "delete"]
}
]
}This creates an RLS policy that only allows UPDATE/DELETE for members who have the manage_agents permission.
Key Behaviors
- Additive model — permissions are ORed together; having a permission from any source (profile, grant, default) is sufficient
- Admin/owner bypass — admins and owners implicitly have all permissions within their scope
- Scope isolation — permissions in one entity do not carry to another; each entity has its own permission space
- Automatic registration — modules register their permissions on install; no manual setup needed
- Name-based API — always work with permission names in application code; the underlying values are resolved automatically
Permission Defaults
When modules are installed, the platform automatically registers named permissions and sets default access levels. New members receive these defaults on join — no manual configuration needed for base-level access.
How Defaults Work
Module Installed (e.g., agent_module)
├── 1. Registers named permissions (invoke_agents, manage_agents)
├── 2. Sets default permissions (invoke_agents → all members)
└── 3. New members automatically receive the default on joinThis three-step process is fully automatic. You only need to intervene when you want to override the defaults for your specific app or entity.
What Members Get Automatically
When a user joins (via sign-up, invite, or direct membership creation), they receive:
1. Module defaults — base permissions from all installed modules 2. Permission defaults — any custom defaults configured by an admin 3. Default profile — if a profile with isDefault: true exists, it's assigned automatically
These are additive — the effective initial access is the union of all three sources.
Module Default Permissions
Each module declares what permissions members should get out of the box:
| Module | Granted to All Members | Admin-Only |
|---|---|---|
| Agent | invoke_agents | manage_agents |
| Function | invoke_functions | manage_functions |
| Graph | execute_graphs | manage_graphs |
| Storage | write_files, delete_files | manage_storage |
| Events | — | (all admin-only) |
| Billing | — | (all admin-only) |
| Hierarchy | — | (all admin-only) |
| Namespace | — | (all admin-only) |
| Notifications | — | (all admin-only) |
| Rate Limits | — | (all admin-only) |
| Usage | — | (all admin-only) |
"Granted to All Members" means these permissions are included in the default permission value automatically. "Admin-Only" means only admin/owner roles have these — they're registered but not included in defaults.
Reading Current Defaults
// Read app-level defaults
const defaults = await db.appPermissionDefault.findMany({
select: { id: true, permissions: true }
}).execute();
// Read org-level defaults (per entity)
const orgDefaults = await db.orgPermissionDefault.findMany({
where: { entityId: { equalTo: orgId } },
select: { id: true, permissions: true }
}).execute();Overriding Defaults
Admins can customize what permissions new members receive. This overrides the module-level defaults:
Setting Custom Defaults
// Set custom defaults for the app (all new app members get these)
await db.appPermissionDefault.create({
data: { permissions: customPermissionValue },
select: { id: true }
}).execute();
// Set custom defaults for a specific org
await db.orgPermissionDefault.create({
data: {
permissions: customPermissionValue,
entityId: orgId
},
select: { id: true }
}).execute();Updating Existing Defaults
await db.appPermissionDefault.update({
where: { id: defaultId },
data: { permissions: newPermissionValue },
select: { id: true }
}).execute();Building the Permission Value
// Resolve desired permission names to a value
const result = await db.query.appPermissionsGetMaskByNames({
names: 'invoke_agents,write_files,execute_graphs'
}).execute();
const customPermissionValue = result.permissions;Default Grants (Audit Trail)
Changes to permission defaults are tracked:
// View history of default permission changes
const defaultGrants = await db.appPermissionDefaultGrant.findMany({
select: {
id: true,
permissions: true,
isGrant: true, // true = permissions added to default, false = removed
grantorId: true,
createdAt: true
},
orderBy: { createdAt: 'DESC' }
}).execute();Interaction with Profiles
Permission defaults and profiles are independent but additive:
| Source | When Applied | Scope |
|---|---|---|
| Module defaults | On module install | Automatic for all new members |
| Permission defaults | On member join | Per-entity (app/org/custom) |
| Default profile | On member join (if isDefault: true profile exists) | Per-entity |
If all three exist, the new member's initial permissions = module defaults ∪ permission defaults ∪ default profile permissions.
Entity-Level vs App-Level
- App-level defaults apply to all new app members regardless of organization
- Org-level defaults apply only to new members of that specific organization
- Custom entity defaults apply only to new members of that specific entity
Org-level defaults override app-level defaults for org memberships — they don't add on top. Each scope manages its own defaults independently.
Key Behaviors
- Automatic on module install — module defaults are applied without any SDK calls
- Applied at join time — defaults are resolved when the membership is created, not retroactively
- Independent of profiles — defaults and profiles are separate systems that compose additively
- Per-entity customization — each organization (or custom entity) can have its own defaults
- Audit preserved — all changes to defaults are tracked in the grants audit log
Profiles
Profiles are reusable permission bundles — named roles like "Editor", "Viewer", or "Manager" that package a set of permissions together. When assigned to a membership, the profile's permissions are added to that member's effective access.
How Profiles Work
Profile "Editor"
└── includes: invoke_agents, write_files, execute_graphs
Member assigned "Editor" profile
└── effective permissions = profile permissions ∪ direct grants ∪ defaults- Each profile contains a set of named permissions
- Assigning a profile to a membership adds those permissions to the member's effective access
- A member can have at most one profile per scope (but can also have direct grants on top)
- Admins and owners always have full permissions regardless of profile
Enabling Profiles
Profiles are enabled per entity type. You must explicitly opt in.
Via Blueprint
{
"entity_types": [
{
"name": "Organization",
"prefix": "org",
"hasProfiles": true
}
]
}Via ORM
await db.entityTypeProvision.create({
data: {
databaseId: dbId,
name: 'Organization',
prefix: 'org',
hasProfiles: true
},
select: { id: true }
}).execute();When enabled, the following tables are created (prefixed by scope):
| Table | Purpose |
|---|---|
{prefix}Profile | Profile definitions (name, slug, permissions, isDefault, isSystem) |
{prefix}ProfilePermission | Join table linking profiles to named permissions |
{prefix}ProfileGrant | Audit log of profile assignments/unassignments |
{prefix}ProfileDefinitionGrant | Audit log of permission additions/removals from profiles |
Creating Profiles
// Create an "Editor" profile at org scope
await db.orgProfile.create({
data: {
name: 'Editor',
slug: 'editor',
entityId: orgId,
permissions: editorPermissionValue // from permissionsGetMaskByNames
},
select: { id: true }
}).execute();
// Create a "Viewer" profile (read-only, fewer permissions)
await db.orgProfile.create({
data: {
name: 'Viewer',
slug: 'viewer',
entityId: orgId,
permissions: viewerPermissionValue
},
select: { id: true }
}).execute();Building the Permission Value
// Resolve permission names to a value for the profile
const result = await db.query.orgPermissionsGetMaskByNames({
names: 'invoke_agents,write_files,execute_graphs'
}).execute();
const editorPermissionValue = result.permissions;Default Profiles
A profile with isDefault: true is automatically assigned to new members when they join:
await db.orgProfile.create({
data: {
name: 'Member',
slug: 'member',
entityId: orgId,
permissions: memberPermissionValue,
isDefault: true
},
select: { id: true }
}).execute();Constraint: Only one profile per scope can be the default. Setting a new default requires unsetting the previous one.
// Change the default profile
await db.orgProfile.update({
where: { id: oldDefaultId },
data: { isDefault: false }
}).execute();
await db.orgProfile.update({
where: { id: newDefaultId },
data: { isDefault: true }
}).execute();System Profiles
Profiles with isSystem: true are platform-managed and cannot be deleted or renamed by users:
await db.orgProfile.create({
data: {
name: 'Admin',
slug: 'admin',
entityId: orgId,
permissions: allPermissionsValue,
isSystem: true
},
select: { id: true }
}).execute();Assigning Profiles to Members
Direct Assignment
// Assign a profile to a member
await db.orgMembership.update({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgId } },
data: { profileId: editorProfileId }
}).execute();Via Invite
Email invites can carry a profileId that pre-assigns the profile when the invite is claimed:
await db.orgInvite.create({
data: {
email: 'newuser@example.com',
senderId: currentUserId,
entityId: orgId,
profileId: editorProfileId
}
}).execute();See `constructive-entities` → invites.md for invite profile assignment modes and permission checks.
Removing a Profile
// Remove profile from a member (they keep only direct grants + defaults)
await db.orgMembership.update({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgId } },
data: { profileId: null }
}).execute();Listing Profiles
// List all profiles for an org
const profiles = await db.orgProfile.findMany({
where: { entityId: { equalTo: orgId } },
select: {
id: true,
name: true,
slug: true,
isDefault: true,
isSystem: true,
permissions: true
}
}).execute();Profile Permissions (Join Table)
For granular management of which permissions a profile includes:
// Add a permission to a profile
await db.orgProfilePermission.create({
data: {
profileId: editorProfileId,
permissionId: writeFilesPermId
},
select: { id: true }
}).execute();
// List permissions in a profile
const profilePerms = await db.orgProfilePermission.findMany({
where: { profileId: { equalTo: editorProfileId } },
select: { id: true, permissionId: true }
}).execute();Audit Trail
Profile changes are tracked via append-only audit logs:
Profile Assignments (ProfileGrants)
// View profile assignment history for a member
const history = await db.orgProfileGrant.findMany({
where: { actorId: { equalTo: userId }, entityId: { equalTo: orgId } },
select: {
id: true,
profileId: true,
isGrant: true, // true = assigned, false = unassigned
grantorId: true,
createdAt: true
},
orderBy: { createdAt: 'DESC' }
}).execute();Profile Definition Changes (ProfileDefinitionGrants)
// View permission changes to a profile definition
const defHistory = await db.orgProfileDefinitionGrant.findMany({
where: { profileId: { equalTo: editorProfileId } },
select: {
id: true,
permissions: true,
isGrant: true, // true = permissions added, false = permissions removed
grantorId: true,
createdAt: true
},
orderBy: { createdAt: 'DESC' }
}).execute();Key Behaviors
- One profile per membership — a member can only have one profile at a time per scope; switching profiles replaces the previous one
- Additive with grants — profile permissions are unioned with direct grants; revoking a profile does not remove direct grants
- Admin bypass — admins and owners have all permissions regardless of profile assignment
- Profile ≠ Role — profiles are configurable bundles; roles (
isAdmin,isOwner) are structural and not profile-dependent - Scope isolation — profiles in one org don't affect another org; each entity has its own profile set
Org Hierarchy
The hierarchy system provides organizational chart (org chart) capabilities — manager/subordinate relationships within an entity (org, team, etc.) that can drive access control via the AuthzOrgHierarchy policy. It's an optional module installed per entity type (typically at org scope via the b2b preset).
Concepts
| Concept | Description |
|---|---|
| Hierarchy Module | Per-entity-type module that provisions the chart edges, grants, and traversal functions |
| Chart Edges | Direct parent→child relationships in the org chart (one level) |
| Chart Edge Grants | Append-only audit log of hierarchy changes (who added/removed whom, and when) |
| Closure Table | Pre-computed transitive relationships — if A manages B and B manages C, the closure table stores A→C (enables $O(1)$ lookups for "all subordinates of X") |
| Direction | Access direction: down = managers see subordinate data; up = subordinates see manager data |
| Rebuild | After hierarchy changes, the closure table is rebuilt to reflect the new transitive paths |
When to Use
- Reporting structures — managers can view direct reports' documents, timesheets, reviews
- Cascading visibility — directors see everything their entire sub-tree produces
- Approval chains — route approvals up the hierarchy
- Scoped dashboards — show aggregated metrics for the current user's sub-tree
Enabling the Hierarchy
The hierarchy module is installed per entity type. It's included automatically in the `b2b` preset:
// The b2b preset includes hierarchy at org scope
['hierarchy_module', { scope: 'org' }]Or install it directly via the modules API:
await db.query.installModule({
input: {
databaseId: dbId,
moduleName: 'hierarchy_module',
scope: 'org'
}
}).execute();Building the Org Chart
Hierarchy relationships are managed through the chart edge grants table — an append-only audit log (same pattern as admin/owner grants). Each record places a user under a parent in the org chart.
Adding a User to the Hierarchy
// Place a user under a manager in an org
await db.orgChartEdgeGrant.create({
data: {
entityId: orgId, // which org
childId: employeeId, // user being placed
parentId: managerId, // their manager (null = top of chart)
grantorId: currentUserId, // who made this change
isGrant: true // true = add, false = remove
},
select: { id: true }
}).execute();# CLI equivalent
constructive public:org-chart-edge-grant create \
--data.entityId $ORG_ID \
--data.childId $EMPLOYEE_ID \
--data.parentId $MANAGER_ID \
--data.grantorId $CURRENT_USER_ID \
--data.isGrant trueTop-Level Users (No Manager)
Users at the top of the hierarchy have parentId: null:
// CEO / top-level — no parent
await db.orgChartEdgeGrant.create({
data: {
entityId: orgId,
childId: ceoId,
parentId: null, // top of chart
grantorId: currentUserId,
isGrant: true
},
select: { id: true }
}).execute();Removing from Hierarchy
Insert a record with isGrant: false to remove a user from the chart:
await db.orgChartEdgeGrant.create({
data: {
entityId: orgId,
childId: employeeId,
parentId: managerId,
grantorId: currentUserId,
isGrant: false // revoke the edge
},
select: { id: true }
}).execute();Example: Building an Org Chart
// Build a simple hierarchy:
// CEO
// ├── VP Engineering
// │ ├── Team Lead
// │ │ ├── Developer 1
// │ │ └── Developer 2
// │ └── Manager 2
// └── VP Sales
const edges = [
{ child: ceoId, parent: null },
{ child: vpEngId, parent: ceoId },
{ child: vpSalesId, parent: ceoId },
{ child: teamLeadId, parent: vpEngId },
{ child: manager2Id, parent: vpEngId },
{ child: dev1Id, parent: teamLeadId },
{ child: dev2Id, parent: teamLeadId },
];
for (const edge of edges) {
await db.orgChartEdgeGrant.create({
data: {
entityId: orgId,
childId: edge.child,
parentId: edge.parent,
grantorId: adminUserId,
isGrant: true
},
select: { id: true }
}).execute();
}AuthzOrgHierarchy Policy
The AuthzOrgHierarchy RLS policy enforces visibility based on hierarchy position. Attach it to any table to restrict row access based on manager/subordinate relationships.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
direction | `'up' \ | 'down'` | Yes |
anchor_field | column ref | Yes | Field on the table that identifies the row's owner (e.g., owner_id) |
entity_field | column ref | No | Field referencing the entity (defaults to entity_id) |
max_depth | integer | No | Limit visibility to N levels deep in the hierarchy |
Direction: down (Most Common)
Managers can see rows created by their subordinates (at any depth in their sub-tree):
// Blueprint node: managers see subordinate projects
{
"$type": "AuthzOrgHierarchy",
"data": {
"direction": "down",
"anchor_field": "owner_id",
"entity_field": "entity_id"
}
}Access pattern:
- CEO sees all projects in the org
- VP sees projects from their managers and developers
- Team Lead sees projects from their direct reports
- Developer sees only their own projects (no subordinates)
Direction: up
Subordinates can see rows owned by their managers (useful for published guidance, announcements):
{
"$type": "AuthzOrgHierarchy",
"data": {
"direction": "up",
"anchor_field": "owner_id",
"entity_field": "entity_id"
}
}Max Depth
Limit visibility to a fixed number of levels:
// Only direct manager can see (1 level up)
{
"$type": "AuthzOrgHierarchy",
"data": {
"direction": "down",
"anchor_field": "owner_id",
"max_depth": 1
}
}Composing with Other Policies
AuthzOrgHierarchy is typically combined with AuthzDirectOwner (so users always see their own rows) and scoped to an entity:
// Full pattern: own rows + hierarchy visibility
{
"nodes": [
{
"$type": "AuthzOrgHierarchy",
"operations": ["select"],
"data": {
"direction": "down",
"anchor_field": "owner_id",
"entity_field": "entity_id"
}
},
{
"$type": "AuthzDirectOwner",
"operations": ["select", "update", "delete"],
"data": { "entity_field": "owner_id" }
},
{
"$type": "AuthzAllowAll",
"operations": ["insert"]
}
]
}Entity Isolation
The hierarchy is entity-scoped — each org has its own independent hierarchy. Users in Org A cannot see data from Org B through hierarchy traversal, even if they share the same hierarchy structure.
Org A: Org B:
CEO-A CEO-B
└── Manager-A └── Manager-B
└── Dev-A └── Dev-B
Dev-A's data is invisible to CEO-B (different entity_id)Membership Lifecycle Integration
The hierarchy integrates with membership lifecycle:
- Adding to hierarchy — a user must be an active member of the entity before they can be placed in its hierarchy
- Removing from hierarchy — removing a user's hierarchy edge immediately revokes any hierarchy-derived visibility
- Deactivating membership — removing a member from the org also removes their hierarchy-derived access (the closure table reflects active relationships only)
Hierarchy Module Resources
When provisioned, the hierarchy module creates these resources per entity type:
| Resource | Purpose |
|---|---|
| Chart Edges Table | Stores direct parent→child relationships |
| Chart Edge Grants Table | Append-only audit log of hierarchy changes |
| Closure Table | Pre-computed transitive ancestor/descendant paths |
rebuildHierarchy() | Rebuilds the closure table after edge changes |
getSubordinates(entityId, userId) | Returns all subordinate user IDs |
getManagers(entityId, userId) | Returns all manager user IDs (up the chain) |
isManagerOf(entityId, managerId, subordinateId) | Boolean check for a specific relationship |
Presets That Include Hierarchy
| Preset | Hierarchy Scope | Description |
|---|---|---|
b2b | org | Full B2B SaaS with nested org structures |
Other presets (auth:email, auth:hardened) do not include hierarchy — it's an opt-in module for apps that need organizational chart capabilities.