The Doh permission system authorizes access through two qualification gates, both evaluated at Doh.permit() call time:
Gate 1 — Group Conditions (Membership Gates): "Does this user qualify for this group, given this context?"
Each permission group can have a condition function. If it accepts (user, context), it runs dynamically on every Doh.permit() call. If it accepts (user), it runs once at login and is cached. Either way, the condition is a membership gate — it decides whether the user qualifies as a member of that group right now.
Gate 2 — Context Conditions (Type Guards): "Does this runtime object qualify as this context type?"
Each permission context has a condition function (user, context) that validates whether the runtime object passed to Doh.permit() actually represents the named context type. This is a type guard — it checks shape, ownership, route, or any property of the object.
Doh.permit(user, 'edit:cloud_instance', instanceObj)
│
├─ Gather groups: user.groups (static/cached)
│ + dynamic groups whose condition(user, instanceObj) → true ← GATE 1
│
├─ Collect granted + negated permissions from all groups + inheritance
│
├─ Check negations → if negated → DENY
│
├─ Validate context: PermissionContexts['cloud_instance']
│ .condition(user, instanceObj) → true? ← GATE 2
│ If false → DENY
│
├─ Check grants: exact → action wildcard → context wildcard → universal
│ If granted → ALLOW
│
└─ Default → DENY
Both gates evaluate at the last second — not at definition time, not at login time. This enables infinitely granular, stateful permission models that adapt to current reality.
Operations like read, edit, create, delete, publish. Actions are implicit — they exist by being used in permission strings. No registration required.
Defined via Doh.definePermissionContext(name, conditionFn). The condition function receives (user, context) and returns true if the runtime object qualifies as this context type.
// Structural type guard: any object with a username is a 'user' context
Doh.definePermissionContext('user', (user, context) => {
return HasValue(context) && HasValue(context.username);
});
// Relationship guard: only matches when context IS the acting user
Doh.definePermissionContext('current_user', (user, context) => {
return HasValue(user) && HasValue(context) &&
HasValue(user.username) && HasValue(context.username) &&
context.username === user.username;
});
The same runtime object can qualify for both — a user object with {username: 'alice'} passes 'user' for any authenticated caller, but only passes 'current_user' when Alice herself is the caller. This is how the same data supports different perspectives.
Defined via the condition option of Doh.definePermissionGroup(). Determines whether a user qualifies for group membership.
condition(user) — static, evaluated once at loadUserPermissionGroups() and cachedcondition(user, context) — dynamic, evaluated on every Doh.permit() call// Dynamic ownership check — runs every Doh.permit() call
Doh.definePermissionGroup('cloud_instance_owner', {
condition: (user, context) => {
if (!context?.id || !context?.userId) return false;
const isDirectOwner = user.username === context.userId;
const hasSharedAccess = checkSharedAccess(user, context);
return isDirectOwner || hasSharedAccess;
},
permissions: ['read:cloud_instance', 'update:cloud_instance', 'delete:cloud_instance']
});
Format: action:context. Wildcards: *:context (any action), action:* (any context), *:* (everything). Negation prefix: ~~ (e.g., ~~delete:cloud_instance).
The actual object passed to Doh.permit() that both gates receive and evaluate against. This could be a database record, a request path wrapper, a user object, or any object whose shape can be validated.
When Doh.permit(user, 'action:context', runtimeObject) is called, checkPermission() executes these steps in order:
Step 1 — Gather applicable groups. Combine the user's static groups (user.groups, cached at login) with dynamic groups evaluated NOW against the runtime object. Each dynamic group's condition(user, runtimeObject) is called.
Step 2 — Collect permissions. Walk all applicable groups and their inheritance chains. Gather two sets: granted permission strings and negated permission strings (those prefixed with ~~ in group definitions).
Step 3 — Check negations. If the requested permission is negated, deny immediately. Check order: exact match → action wildcard (~~*:context) → context wildcard (~~action:*) → universal (~~*:*).
Step 4 — Validate context definition. Look up Doh.PermissionContexts[contextName]. If the context name isn't registered, deny. This catches typos and missing definitions.
Step 5 — Run context condition (TYPE GUARD). Call contextDefinition.condition(user, runtimeObject). If it returns false, deny. This is the step that validates the runtime object qualifies as the named context type. Without this, a permission like 'edit:cloud_instance' could be checked against a random object that isn't actually a cloud instance.
Step 6 — Check grants. Check if the permission is granted. Check order: exact match → action wildcard (*:context) → context wildcard (action:*) → universal (*:*). First match allows.
Step 7 — Default deny. If nothing matched, deny.
The argument count of the condition function determines when it's evaluated:
| Condition Signature | Timing | Storage |
|---|---|---|
condition(user) |
Once, at loadUserPermissionGroups() |
Cached in user.groups |
condition(user, context) |
Every Doh.permit() call |
Evaluated fresh each time |
| No condition | Never auto-qualifies | Only via explicit user.groups assignment |
The context parameter doesn't need to be used. Its presence alone switches the group to dynamic evaluation:
// This is dynamic even though context is unused — evaluated every Doh.permit() call
Doh.definePermissionGroup('feature_user', {
condition: (user, context) => !!user?.username, // 2 args = dynamic
permissions: ['read:my_feature']
});
Why this matters: Modules that load after user authentication (common for on-demand features) MUST use 2-arg conditions. A 1-arg condition defined after login will never be evaluated because loadUserPermissionGroups() already ran. The 2-arg form ensures the group is checked dynamically on every Doh.permit() call, regardless of when the module loaded.
The inherits array in group definitions builds layered perspectives over the same domain. Different groups grant different views of the same objects through different qualification gates.
Three groups, same domain (cloud_instance), three perspectives:
| Group | Type | Gate | Perspective |
|---|---|---|---|
cloud_admin |
Assignable, no condition | Manual assignment | Full control over ALL instances (*:cloud_instance) |
cloud_instance_owner |
Dynamic 2-arg condition | Ownership + shared access check | CRUD on instances the user owns or has shared access to |
cloud_user |
Assignable, inherits authenticated_cloud_user |
Manual assignment | Can create instances and view dashboard |
The same instanceObj passes through the same 'cloud_instance' context type guard, but different users see different permissions based on which groups they qualify for.
| Group | Type | Perspective |
|---|---|---|
detract_admin |
Assignable | Bypass ACL — full access to all nodes |
detract_user |
Assignable, inherits detract_creator + detract_node_access |
ACL-filtered CRUD |
detract_viewer |
Assignable, dynamic condition | Read-only, ACL-filtered |
Groups can exclude inherited permissions using ~~ prefix in the inherits array (via Pod config) or ~~ prefix on permission strings:
# Pod config: site_moderator inherits from editor but NOT delete permissions
Users:
groups:
site_moderator:
inherits:
- editor
permissions:
- '~~delete:document' # Explicitly strip delete even though editor has it
Warning: Negations cannot be overridden by child groups. A ~~delete:document in a parent group means no child group can grant delete:document. Use sparingly.
Both gates run at Doh.permit() call time, not at definition time. This has powerful implications:
async and query databases. The file_recipient group in user_file_storage.js queries a share database table in its condition function.Doh.permit() calls, the second call sees the new state.Doh.checkContext(user, contextName, context) is a convenience helper for reusing context validation inside group conditions — it calls the named context's condition function directly.// Group condition that reuses context validation + queries DB
Doh.definePermissionGroup('file_recipient', {
permissions: ['read:file'],
condition: async function(user, context) {
// Reuse the 'file' context's type guard
if (!Doh.checkContext(user, 'file', context) || !user?.id) return false;
// Then check share database
return await checkShareExists(context.dohPath, user.id);
}
});
1. Structural type guards — validate object shape:
// A 'file' context requires dohPath and ownerId properties
Doh.definePermissionContext('file', (user, context) => {
return IsObject(context) && HasValue(context.dohPath) && HasValue(context.ownerId);
});
2. Relationship guards — validate user↔object relationship:
// 'current_user' only matches when the context user IS the acting user
Doh.definePermissionContext('current_user', (user, context) => {
return HasValue(user?.username) && HasValue(context?.username) &&
context.username === user.username;
});
3. Route guards — validate request path:
// 'cloud_dashboard' matches a specific admin route
Doh.definePermissionContext('cloud_dashboard', (user, context) => {
return context && context.path === '/admin/cloud';
});
4. Inherited contexts — reuse another context's condition via string shorthand:
// 'user_profile' uses the same condition as 'user'
Doh.definePermissionContext('user_profile', 'user');
Doh.definePermissionContext('current_user_profile', 'current_user');
1. Static assignable (no condition) — membership only via explicit assignment:
Doh.definePermissionGroup('superadmin', {
assignable: true,
permissions: ['*:*']
});
2. Static conditional (1-arg) — auto-qualify at login:
Doh.definePermissionGroup('authenticated_user', {
condition: (user) => HasValue(user?.username),
permissions: ['read:current_user', 'update:current_user']
});
3. Dynamic conditional (2-arg) — evaluated every Doh.permit() call:
Doh.definePermissionGroup('cloud_instance_owner', {
condition: (user, context) => {
if (!context?.id || !context?.userId) return false;
return user.username === context.userId || checkSharedAccess(user, context);
},
permissions: ['read:cloud_instance', 'update:cloud_instance', 'delete:cloud_instance']
});
4. Async dynamic — condition queries a database:
Doh.definePermissionGroup('file_recipient', {
permissions: ['read:file'],
condition: async function(user, context) {
if (!Doh.checkContext(user, 'file', context) || !user?.id) return false;
return await checkShareExists(context.dohPath, user.id);
}
});
5. Inheriting groups — compose permissions from multiple sources:
Doh.definePermissionGroup('detract_user', {
assignable: true,
inherits: ['detract_creator', 'detract_node_access']
});
Doh.definePermissionGroup('remote_state_admin', {
assignable: true,
inherits: ['remote_state_editor'], // editor inherits viewer
permissions: ['*:remote_state']
});
Groups can be defined or extended in pod.yaml or via Doh.Pod() under Users.groups:
Users:
groups:
site_admin:
assignable: true
inherits:
- user_admin
- cloud_admin
- analytics_viewer
content_moderator:
assignable: true
permissions:
- 'read:document'
- 'update:document'
- '~~delete:document' # Explicitly deny delete
Rules:
permissions and inherits to existing groups.assignable can be set in Pod config. If both code and Pod define it, Pod wins.condition functions cannot be defined in Pods (they require JavaScript). Code-defined conditions are retained during merge.~~group_name) in the inherits array remove that group from the inheritance chain.The older Users.permit() middleware approach lacks context awareness:
// ❌ DEPRECATED: Middleware without context
Router.AddRoute('/api/articles/:id', [Users.permit('edit:article')], handler);
Use global authentication middleware with direct contextual checks:
// ✅ RECOMMENDED: Direct check with runtime context
Router.AddRoute('/api/articles/:id', [Users.requireAuth], async function(data, req, res) {
const article = await getArticle(req.params.id);
if (!await Doh.permit(req.user, 'edit:article', article)) {
return Doh.deny(req, res);
}
// Proceed with editing...
});
The Permission System includes an interactive Permission Explorer at /admin/permissions for visualizing contexts, groups, inheritance networks, and simulating permission checks.
See PERMISSION_EXPLORER.md for full documentation of the admin tools.
condition(user, context) to ensure dynamic evaluation.Doh.checkContext() in group conditions to reuse context validation without duplicating the logic.~~ negation. A user without 'delete:document' in their groups simply can't delete. Negations are for removing inherited grants.~~ negations cannot be overridden by child groups. Once negated in a parent, no descendant can grant it back. Use sparingly and only for final overrides.Doh.deny(req, res) for standardized denial responses — handles both API (JSON) and HTML (redirect/403 page) automatically.