Unified Permissions Guide
How MemberJunction answers “can this user do this?” across every kind of resource — agents, artifacts, dashboards, queries, collections, entity rows, and anything you add — through one normalized model instead of a dozen bespoke checks.
Audience: anyone gating an action, building a sharing UI, auditing access, or adding permissions to a new resource type.
1. The mental model: three concerns, one unifier
Section titled “1. The mental model: three concerns, one unifier”“Permissions” in MJ is not one thing. It’s three distinct concerns, and conflating them is the #1 source of confusion:
| Concern | Question it answers | Mechanism | Where it lives |
|---|---|---|---|
| Capability | ”Is this user allowed the feature at all?” | Authorizations (named, hierarchical grants) | MJ: Authorizations + AuthorizationEvaluator |
| Row visibility (CRUD) | “Which rows of this entity can this user read/create/update/delete?” | Entity Permissions / RLS (SQL WHERE injection) | MJ: Entity Permissions + getRowLevelSecurityWhereClause |
| Per-resource access / sharing | ”Can this user use / view / edit / own this specific record?” | Permission Providers → the PermissionEngine | PermissionProviderBase + PermissionEngine |
The third concern is the one that’s “unified” — and it’s the subject of this guide. Every domain that has per-record access (an agent you can run, an artifact shared with you, a dashboard someone gave you Edit on) exposes its answer through one common interface so that sharing UIs, audit timelines, and programmatic checks treat them all identically.
flowchart TB
subgraph Q["A permission question"]
direction TB
A["Can user U…"]
end
A -->|"…use the Skills FEATURE?"| CAP["Authorizations<br/>AuthorizationEvaluator.UserCanExecuteWithAncestors"]
A -->|"…SEE these entity rows?"| RLS["Entity RLS<br/>getRowLevelSecurityWhereClause"]
A -->|"…RUN / VIEW / EDIT this specific record?"| PE["PermissionEngine<br/>(unified across all domains)"]
style CAP fill:#7c5295,stroke:#563a6b,color:#fff
style RLS fill:#b8762f,stroke:#8a5722,color:#fff
style PE fill:#2d8659,stroke:#1a5c3a,color:#fff
Rule of thumb. Gate a button/feature → Authorization. Filter a list query → Entity RLS. Check access to a specific record → PermissionEngine. They compose: e.g. the skill Share button is gated by the
Can Share SkillsAuthorization, while who a skill is shared with is a PermissionEngine domain.
2. The unified core: PermissionProviderBase + PermissionEngine
Section titled “2. The unified core: PermissionProviderBase + PermissionEngine”2.1 The normalized vocabulary
Section titled “2.1 The normalized vocabulary”Every domain maps its native storage (CRUD booleans, View/Edit/Owner levels, custom flags) onto one shared vocabulary (packages/MJCore/src/generic/permissionInterfaces.ts):
PermissionAction='Read' | 'Create' | 'Update' | 'Delete' | 'Share' | 'Execute' | 'Admin'GranteeType='User' | 'Role' | 'Everyone' | 'Public'NormalizedPermission— the uniform record every provider emits:{ DomainName, ResourceType, ResourceID, ResourceName?, GranteeType, GranteeID, GranteeName?, Actions[], Effect: 'Allow'|'Deny', SourceRecordID?, ExpiresAt? }PermissionCheckResult={ Allowed, DomainName, Reason, MatchedPermission? }— note theReasonstring; every decision is self-explaining, which is what makes the audit UI and debug logs readable.
Because a CanRun bit and an AccessLevel='Edit' enum and an Access-Control-Rule all collapse to Actions: PermissionAction[], a sharing UI can render any domain without knowing its storage.
2.2 The provider contract
Section titled “2.2 The provider contract”A domain implements PermissionProviderBase (@memberjunction/core) — an abstract class registered with the ClassFactory:
@RegisterClass(PermissionProviderBase, 'MJAIAgentPermissionProvider')export class AIAgentPermissionProvider extends PermissionProviderBase { readonly DomainName = 'AI Agent Permissions'; readonly Description = 'User- or role-level permissions on AI agents. Actions: View (Read), Run (Execute), Edit (Update), Delete.'; readonly SupportedGranteeTypes: GranteeType[] = ['User', 'Role']; readonly SupportedActions: PermissionAction[] = ['Read', 'Execute', 'Update', 'Delete']; readonly SupportsDeny = false;
GetResourceTypes(): string[] { return ['AI Agents']; }
// The four data methods every provider implements: CheckPermission(user, resourceType, resourceId, action): Promise<PermissionCheckResult>; GetEffectivePermissions(user, resourceType, resourceId): Promise<NormalizedPermission[]>; GetUserResources(user, resourceType?): Promise<NormalizedPermission[]>; // "everything this user can touch" GetResourcePermissions(resourceType, resourceId): Promise<NormalizedPermission[]>; // "who can touch this record"}The base class hands you the tedious parts so a provider stays ~150 lines:
boolsToActions({ Read: row.CanView, Execute: row.CanRun, … })— collapse native flags to the normalized action list.fetchRows<T>(entityName, filter, fields, label)— provider-scopedRunViewwith logging.bulkLookupNames(entityName, ids[])— resolve resource IDs → display names in one query.buildNormalizedPermission({ … })— assemble aNormalizedPermissionwithEffect: 'Allow'defaulted.
2.3 The aggregator: PermissionEngine
Section titled “2.3 The aggregator: PermissionEngine”PermissionEngine (packages/MJCoreEntities/src/engines/PermissionEngine.ts) is a @RegisterForStartup BaseEngine singleton that discovers and fans out across every registered provider:
- On
Config(), it loads theMJ: Permission Domainscatalog (each row:Name,ProviderClassName,IsActive). - For each active domain, it
ClassFactory.CreateInstance(PermissionProviderBase, domain.ProviderClassName)and stores it in adomainName → providermap. (So adding a domain is data + a class, never an edit to the engine.) - It exposes the unified API:
| Method | Answers | Powers |
|---|---|---|
CheckPermission(user, domain, resourceType, resourceId, action) | ”Is this one action allowed?” | Programmatic gates |
AuthorizeOrThrow(user, domain, resourceType, resourceId, action) | same, but throws PermissionDeniedError | Server-side enforcement |
GetAllUserPermissions(user) | ”Everything, everywhere, this user can access” (parallel across all providers) | Sharing Center → User Access Report |
GetPermissionsSharedWithUser(grantee) | ”What was shared with me” | Sharing Center inbox |
GetPermissionsGrantedByUser(grantor) | ”What I’ve shared with others” | Sharing Center outbox |
GetResourcePermissions(domain, resourceType, resourceId) | ”Everyone who can touch this record” | Per-record share dialog |
GetAuditTimeline(filter) | ”Change history across permission tables” (via RecordChange) | Audit UI |
sequenceDiagram
participant UI as Sharing Center / caller
participant PE as PermissionEngine
participant CAT as MJ: Permission Domains
participant CF as ClassFactory
participant P1 as AIAgentPermissionProvider
participant P2 as ArtifactPermissionProvider
participant Pn as …7 more providers
Note over PE,CF: Config() — once at startup
PE->>CAT: load active domains
CAT-->>PE: [{AI Agent Permissions → MJAIAgentPermissionProvider}, …]
PE->>CF: CreateInstance(PermissionProviderBase, ProviderClassName) ×N
CF-->>PE: provider map (domainName → instance)
Note over UI,Pn: GetAllUserPermissions(user)
UI->>PE: GetAllUserPermissions(user)
par fan-out (Promise.allSettled)
PE->>P1: GetUserResources(user)
PE->>P2: GetUserResources(user)
PE->>Pn: GetUserResources(user)
end
P1-->>PE: NormalizedPermission[]
P2-->>PE: NormalizedPermission[]
Pn-->>PE: NormalizedPermission[]
PE-->>UI: merged NormalizedPermission[] (one shape, all domains)
2.4 The domains that exist today
Section titled “2.4 The domains that exist today”Ten providers ship in the catalog (metadata/permission-domains/.permission-domains.json):
Domain (Name) | ProviderClassName | Resource type(s) | Backing storage |
|---|---|---|---|
| Entity Permissions | MJEntityPermissionProvider | (entities) | MJ: Entity Permissions (RLS, brought into the unified model) |
| Application Roles | MJApplicationRolePermissionProvider | (applications) | MJ: Application Roles |
| Dashboard Permissions | MJDashboardPermissionProvider | Dashboards | MJ: Dashboard Permissions |
| Resource Permissions | MJResourcePermissionProvider | Conversations, Reports, Queries, … | MJ: Resource Permissions (polymorphic) |
| Artifact Permissions | MJArtifactPermissionProvider | Artifacts | MJ: Conversation Artifact Permissions |
| Collection Permissions | MJCollectionPermissionProvider | Collections | MJ: Collection Permissions |
| AI Agent Permissions | MJAIAgentPermissionProvider | AI Agents | MJ: AI Agent Permissions |
| AI Skill Permissions | MJAISkillPermissionProvider | AI Skills | MJ: AI Skill Permissions |
| Query Permissions | MJQueryPermissionProvider | Queries | (query permission storage) |
| Access Control Rules | MJAccessControlRuleProvider | (entity records) | MJ: Access Control Rules |
Note the two storage styles that both flow into the same unified model: dedicated per-entity tables (AIAgentPermission, ConversationArtifactPermission — one table per domain, richer per-domain columns) and the polymorphic Resource Permissions table (one table, keyed by ResourceType, for the long tail: Conversations/Reports/Queries). The provider abstraction is exactly what lets them coexist.
3. The two access paths (don’t confuse them)
Section titled “3. The two access paths (don’t confuse them)”Some domains — notably AI Agents and AI Skills — have two ways to ask about access, with different defaults and different purposes. This trips people up, so it’s called out explicitly (AI Skills mirror Agents exactly: AISkillPermissionHelper + AISkillPermissionProvider over MJ: AI Skill Permissions):
| Path | Class | Default when no grant rows exist | Cached? | Use it for |
|---|---|---|---|---|
| Runtime helper | AIAgentPermissionHelper (@memberjunction/ai-engine-base) | Open — anyone can View+Run; only owner can Edit/Delete | Yes (in AIEngineBase) | The hot path: gating BaseAgent execution, filtering the @-mention picker |
| Unified provider | AIAgentPermissionProvider (@memberjunction/core-entities) | Closed — reports only explicit grants | No (per-query) | The Sharing Center, audit, cross-domain “what has been shared” |
Both read the same MJ: AI Agent Permissions table. The helper answers “can this user actually use it right now” (with a friendly open-by-default policy baked in); the provider answers “what has been explicitly granted” (for display/audit). When you add a new permissioned resource that needs a runtime gate, you’ll typically build both: a cached helper for the hot path, and a provider so it shows up in the unified surfaces.
4. Recipe: add permissions to a new resource type
Section titled “4. Recipe: add permissions to a new resource type”This is the exact path a new domain follows (the Agent Skills work uses it verbatim):
flowchart LR
T["1 · Backing table<br/>MJ: <Thing> Permissions<br/>(ThingID, UserID?|RoleID?, Can* flags)"]
--> P["2 · Provider class<br/>extends PermissionProviderBase<br/>@RegisterClass(PermissionProviderBase, 'MJ<Thing>PermissionProvider')"]
--> L["3 · LoadPermissionProviders()<br/>(tree-shaking guard)"]
--> D["4 · Seed MJ: Permission Domains row<br/>(Name, ProviderClassName, IsActive=1)"]
--> H["5 · (optional) Runtime helper<br/>cached, own default policy"]
style T fill:#b8762f,stroke:#8a5722,color:#fff
style P fill:#2d8659,stroke:#1a5c3a,color:#fff
style D fill:#7c5295,stroke:#563a6b,color:#fff
- Backing table — migration for
MJ: <Thing> Permissions, mirroringAIAgentPermission:<Thing>ID, nullableUserIDxorRoleID(table validator enforces exactly-one),CanView/CanRun/CanEdit/CanDeletebits (or a level enum),Comments. Run CodeGen. - Provider — subclass
PermissionProviderBase,@RegisterClass(PermissionProviderBase, 'MJ<Thing>PermissionProvider'), set the readonly metadata, and implement the four methods usingfetchRows/boolsToActions/buildNormalizedPermission. CopyAIAgentPermissionProvider.ts— it’s the canonical template. - Register for load — add it to
LoadPermissionProviders()(packages/MJCoreEntities/src/custom/PermissionProviders/index.ts) so the decorator runs under bundler tree-shaking. - Seed the domain — add a
MJ: Permission Domainsmetadata row (Name,ProviderClassName,IsActive: 1) inmetadata/permission-domains/.permission-domains.jsonandmj sync push.PermissionEngine.Config()now discovers it automatically — no engine code changes. - (Optional) runtime helper — if the resource is checked on a hot path (every run, every keystroke of an autocomplete), add a cached helper in the relevant engine (mirror
AIAgentPermissionHelper) so you’re not doing aRunViewper check, and choose the default policy (open vs. closed) deliberately.
That’s the whole contract. The Sharing Center, GetAllUserPermissions, and the audit timeline pick up the new domain for free the moment the Permission Domains row is active.
5. The other two concerns (so you gate at the right layer)
Section titled “5. The other two concerns (so you gate at the right layer)”5.1 Authorizations — capability/feature gates
Section titled “5.1 Authorizations — capability/feature gates”MJ: Authorizations is a hierarchical tree of named capability grants (e.g. Can Share Skills, Schema Management → Create Entities → Create in UDT Schema). Checked with AuthorizationEvaluator:
const auth = md.Authorizations.find(a => a.Name === 'Can Share Skills');const allowed = new AuthorizationEvaluator().CurrentUserCanExecuteWithAncestors(auth, md);Granting a parent implicitly grants descendants. Use this to gate whether a user gets a feature at all — the Share button, a dashboard, a designer — not per-record access. (See packages/MJCore/src/generic/authEvaluator.ts.)
5.2 Entity Permissions / Row-Level Security — CRUD row filtering
Section titled “5.2 Entity Permissions / Row-Level Security — CRUD row filtering”MJ: Entity Permissions drives getRowLevelSecurityWhereClause(provider, entityName, userPayload, EntityPermissionType.Read|Create|Update|Delete, prefix) (packages/MJServer/src/generic/ResolverBase.ts), which injects a SQL WHERE clause into generated entity queries so users only see rows they’re allowed to. This is query-time, set-level gating — orthogonal to “can I use this one record.” (It’s also surfaced through the unified model via MJEntityPermissionProvider, so entity-level grants show up in the Sharing Center too.)
6. Where to look
Section titled “6. Where to look”| Concern | File |
|---|---|
| Normalized vocabulary + provider contract + base helpers | packages/MJCore/src/generic/permissionInterfaces.ts |
| The aggregator | packages/MJCoreEntities/src/engines/PermissionEngine.ts |
| Concrete providers (copy these) | packages/MJCoreEntities/src/custom/PermissionProviders/*.ts (AIAgentPermissionProvider.ts is the canonical template) |
| Provider load guard | packages/MJCoreEntities/src/custom/PermissionProviders/index.ts (LoadPermissionProviders) |
| Domain catalog (metadata) | metadata/permission-domains/.permission-domains.json |
| Share-entity extensions + share-notification hook | packages/MJCoreEntities/src/custom/Permissions/*.ts |
| Authorizations | packages/MJCore/src/generic/authEvaluator.ts, securityInfo.ts |
| Entity RLS | packages/MJServer/src/generic/ResolverBase.ts (getRowLevelSecurityWhereClause) |
| Angular share UI (generic dialog + adapters) | packages/Angular/Generic/resource-permissions/ |
| Runtime helper example | packages/AI/BaseAIEngine/src/AIAgentPermissionHelper.ts |
7. Related guides
Section titled “7. Related guides”- Agent Skills & Plan Mode Guide — a live worked example of adding a new permission domain (
AI Skill Permissions) end-to-end, including the runtime helper +/skillpicker filtering. - Magic Link Guide — passwordless external-user access; how a restricted role + entity permissions scope what a magic-link guest can reach.
- Search Scopes & RAG Guide — how search results are scoped per user (permissions applied to retrieval).
@memberjunction/ng-resource-permissions(README) — the reusable share dialog + adapter pattern over the polymorphicResource Permissionsdomain.