Permissions
Role-based access control with row and column security
Struere implements a comprehensive permission engine that provides role-based access control (RBAC) with row-level security (scope rules) and column-level security (field masks). Every data operation in the platform passes through this engine.
Architecture
The permission engine lives in platform/convex/lib/permissions/ and consists of five modules:
| Module | File | Responsibility |
|---|---|---|
| Context | context.ts |
Build ActorContext with eager role resolution |
| Evaluate | evaluate.ts |
Policy evaluation with deny-overrides-allow |
| Scope | scope.ts |
Row-level security via scope filters |
| Mask | mask.ts |
Column-level security via field masks |
| Tools | tools.ts |
Tool permission checking and identity modes |
ActorContext
Every request begins by building an ActorContext that captures who is making the request and what they are allowed to do:
interface ActorContext {
organizationId: Id<"organizations">
actorType: "user" | "agent" | "system" | "webhook"
actorId: string
roleIds: Id<"roles">[]
isOrgAdmin?: boolean
environment: "development" | "production"
}
| Field | Description |
|---|---|
organizationId |
The organization boundary for all data access |
actorType |
Whether the caller is a user, agent, system process, or webhook |
actorId |
Unique identifier for the specific actor |
roleIds |
Pre-resolved role IDs (eager resolution at request start) |
isOrgAdmin |
Whether the actor has organization admin privileges |
environment |
Data environment scope (development or production) |
Eager Resolution
Roles are resolved once when the ActorContext is built, not on each permission check. This means the roles are fetched from the database at the start of the request and cached in the context object for the duration of that request.
Context Builders
| Function | Use Case |
|---|---|
buildActorContext() |
For authenticated user requests |
buildSystemActorContext() |
For system operations (triggers, webhooks) |
buildActorContextForAgent() |
For agent execution with environment from API key |
Permission Flow
Every data operation passes through a four-stage pipeline:
Request arrives
│
▼
┌──────────────────────────────────────┐
│ Stage 1: Build ActorContext │
│ │
│ Resolve organization, actor type, │
│ actor ID, environment, and role IDs │
│ (eager resolution). │
│ │
│ System actors are automatically │
│ allowed through all checks. │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Stage 2: Policy Evaluation │
│ │
│ Find all policies matching the │
│ requested resource and action. │
│ │
│ Deny overrides allow: │
│ - Any deny policy → access denied │
│ - At least one allow → proceed │
│ - No matching policies → denied │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Stage 3: Scope Rules (Row-Level) │
│ │
│ Apply scope filters to restrict │
│ which entities are visible. │
│ │
│ Example: Teacher sees only sessions │
│ where data.teacherId matches their │
│ user ID. │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Stage 4: Field Masks (Column-Level) │
│ │
│ Hide or redact specific fields │
│ from the response. │
│ │
│ Example: Teacher cannot see │
│ data.paymentId on sessions. │
└──────────────┬───────────────────────┘
│
▼
Filtered response
Policy Evaluation
Policies define what actions a role can perform on which resources.
Action Types
The platform supports 5 action types:
| Action | Description |
|---|---|
create |
Create a new entity |
read |
Retrieve a single entity by ID |
update |
Modify an existing entity |
delete |
Soft-delete an entity |
list |
Query multiple entities |
Evaluation Rules
- Collect all policies from the actor's roles that match the requested resource and action
- Deny overrides: If any matching policy has
effect: "deny", access is denied regardless of allow policies - Allow required: At least one matching policy must have
effect: "allow"for access to be granted - No match = denied: If no policies match the resource and action, access is denied
API
canPerform(ctx, actorContext, resource, action)
Returns a PermissionResult:
interface PermissionResult {
allowed: boolean
reason?: string
matchedPolicy?: Id<"policies">
evaluatedPolicies?: number
}
assertCanPerform(ctx, actorContext, resource, action)
Throws a PermissionError if access is denied. Used in mutations where denial should halt execution.
Scope Rules (Row-Level Security)
Scope rules restrict which entities an actor can see by filtering query results based on entity data fields.
How Scope Rules Work
When an actor queries entities, scope rules for their role and the target entity type are collected. These rules generate filters that are applied to the query:
Actor queries "session" entities
│
▼
Scope rules for actor's roles + "session" entity type collected
│
▼
Filters generated:
{ field: "data.teacherId", operator: "eq", value: <actor's userId> }
│
▼
Query results filtered to only matching entities
Dynamic Value Resolution
Scope rules support actor.userId as a dynamic value that resolves to the current actor's user ID at query time. This enables rules like "a teacher can only see sessions assigned to them":
{ entityType: "session", field: "data.teacherId", operator: "eq", value: "actor.userId" }
Operators
| Operator | Description |
|---|---|
eq |
Field equals value |
neq |
Field does not equal value |
in |
Field is contained in value |
contains |
Field contains value |
Field Masks (Column-Level Security)
Field masks control which fields an actor can see on an entity, implementing column-level security.
Allowlist Strategy
Field masks use an allowlist strategy: new fields added to an entity type are hidden by default until explicitly allowed in a role's field mask configuration. This is a fail-safe design that prevents accidental data exposure.
Mask Types
| Type | Behavior |
|---|---|
hide |
Removes the field entirely from the response |
redact |
Replaces the field value while keeping the key present |
Example
A teacher role with field masks:
fieldMasks: [
{ entityType: "session", fieldPath: "data.paymentId", maskType: "hide" },
{ entityType: "student", fieldPath: "data.guardianId", maskType: "hide" },
]
When a teacher queries a session, the paymentId field is not present in the response.
Tool Permissions
The permission engine also controls which tools an agent or user can invoke.
Tool Identity Modes
| Mode | Behavior |
|---|---|
inherit |
Tool runs with the caller's permissions |
system |
Tool runs with system-level permissions (environment-aware, isOrgAdmin: true) |
configured |
Tool runs with explicitly configured permissions |
Permission Check
canUseTool(ctx, actorContext, toolName)
Checks if the actor is allowed to use the specified tool based on their role's tool permissions.
Security Properties
The permission engine guarantees the following security properties:
| Property | Description |
|---|---|
| No privileged data paths | Templates, tools, and triggers all go through the permission engine |
| Defense in depth | Organization boundary checked at multiple layers |
| Environment isolation | All queries, roles, configs, and entities scoped to environment |
| Deny-safe | Any deny policy blocks access, regardless of allow policies |
| Fail-safe | New fields hidden by default via allowlist field masking |
| Audit trail | Events capture actor context for all mutations |
System Actor
The system actor bypasses all permission checks. It is used for:
- Trigger execution (automated workflows need full data access)
- Webhook processing (inbound messages need to create entities)
- Internal operations (migrations, system maintenance)
The system actor is built using buildSystemActorContext() and always includes the environment.