Skip to content

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:

ConcernQuestion it answersMechanismWhere 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 PermissionEnginePermissionProviderBase + 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/featureAuthorization. Filter a list queryEntity RLS. Check access to a specific recordPermissionEngine. They compose: e.g. the skill Share button is gated by the Can Share Skills Authorization, 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”

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 the Reason string; 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.

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-scoped RunView with logging.
  • bulkLookupNames(entityName, ids[]) — resolve resource IDs → display names in one query.
  • buildNormalizedPermission({ … }) — assemble a NormalizedPermission with Effect: 'Allow' defaulted.

PermissionEngine (packages/MJCoreEntities/src/engines/PermissionEngine.ts) is a @RegisterForStartup BaseEngine singleton that discovers and fans out across every registered provider:

  1. On Config(), it loads the MJ: Permission Domains catalog (each row: Name, ProviderClassName, IsActive).
  2. For each active domain, it ClassFactory.CreateInstance(PermissionProviderBase, domain.ProviderClassName) and stores it in a domainName → provider map. (So adding a domain is data + a class, never an edit to the engine.)
  3. It exposes the unified API:
MethodAnswersPowers
CheckPermission(user, domain, resourceType, resourceId, action)”Is this one action allowed?”Programmatic gates
AuthorizeOrThrow(user, domain, resourceType, resourceId, action)same, but throws PermissionDeniedErrorServer-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)

Ten providers ship in the catalog (metadata/permission-domains/.permission-domains.json):

Domain (Name)ProviderClassNameResource type(s)Backing storage
Entity PermissionsMJEntityPermissionProvider(entities)MJ: Entity Permissions (RLS, brought into the unified model)
Application RolesMJApplicationRolePermissionProvider(applications)MJ: Application Roles
Dashboard PermissionsMJDashboardPermissionProviderDashboardsMJ: Dashboard Permissions
Resource PermissionsMJResourcePermissionProviderConversations, Reports, Queries, …MJ: Resource Permissions (polymorphic)
Artifact PermissionsMJArtifactPermissionProviderArtifactsMJ: Conversation Artifact Permissions
Collection PermissionsMJCollectionPermissionProviderCollectionsMJ: Collection Permissions
AI Agent PermissionsMJAIAgentPermissionProviderAI AgentsMJ: AI Agent Permissions
AI Skill PermissionsMJAISkillPermissionProviderAI SkillsMJ: AI Skill Permissions
Query PermissionsMJQueryPermissionProviderQueries(query permission storage)
Access Control RulesMJAccessControlRuleProvider(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):

PathClassDefault when no grant rows existCached?Use it for
Runtime helperAIAgentPermissionHelper (@memberjunction/ai-engine-base)Open — anyone can View+Run; only owner can Edit/DeleteYes (in AIEngineBase)The hot path: gating BaseAgent execution, filtering the @-mention picker
Unified providerAIAgentPermissionProvider (@memberjunction/core-entities)Closed — reports only explicit grantsNo (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: &lt;Thing&gt; Permissions<br/>(ThingID, UserID?|RoleID?, Can* flags)"]
    --> P["2 · Provider class<br/>extends PermissionProviderBase<br/>@RegisterClass(PermissionProviderBase, 'MJ&lt;Thing&gt;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
  1. Backing table — migration for MJ: <Thing> Permissions, mirroring AIAgentPermission: <Thing>ID, nullable UserID xor RoleID (table validator enforces exactly-one), CanView/CanRun/CanEdit/CanDelete bits (or a level enum), Comments. Run CodeGen.
  2. Provider — subclass PermissionProviderBase, @RegisterClass(PermissionProviderBase, 'MJ<Thing>PermissionProvider'), set the readonly metadata, and implement the four methods using fetchRows / boolsToActions / buildNormalizedPermission. Copy AIAgentPermissionProvider.ts — it’s the canonical template.
  3. Register for load — add it to LoadPermissionProviders() (packages/MJCoreEntities/src/custom/PermissionProviders/index.ts) so the decorator runs under bundler tree-shaking.
  4. Seed the domain — add a MJ: Permission Domains metadata row (Name, ProviderClassName, IsActive: 1) in metadata/permission-domains/.permission-domains.json and mj sync push. PermissionEngine.Config() now discovers it automatically — no engine code changes.
  5. (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 a RunView per 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 ManagementCreate EntitiesCreate 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.)


ConcernFile
Normalized vocabulary + provider contract + base helperspackages/MJCore/src/generic/permissionInterfaces.ts
The aggregatorpackages/MJCoreEntities/src/engines/PermissionEngine.ts
Concrete providers (copy these)packages/MJCoreEntities/src/custom/PermissionProviders/*.ts (AIAgentPermissionProvider.ts is the canonical template)
Provider load guardpackages/MJCoreEntities/src/custom/PermissionProviders/index.ts (LoadPermissionProviders)
Domain catalog (metadata)metadata/permission-domains/.permission-domains.json
Share-entity extensions + share-notification hookpackages/MJCoreEntities/src/custom/Permissions/*.ts
Authorizationspackages/MJCore/src/generic/authEvaluator.ts, securityInfo.ts
Entity RLSpackages/MJServer/src/generic/ResolverBase.ts (getRowLevelSecurityWhereClause)
Angular share UI (generic dialog + adapters)packages/Angular/Generic/resource-permissions/
Runtime helper examplepackages/AI/BaseAIEngine/src/AIAgentPermissionHelper.ts
  • 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 + /skill picker 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 polymorphic Resource Permissions domain.