Home Doh Ref
Dohballs
  • 📁 doh_chat
  • 📁 doh_modules
    • 📦 dataforge
    • 📦 express
    • 📁 sso
    • 📁 user

Doh Permission System: Context-Aware Authorization

The Two-Gate Model

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.


Core Concepts

Actions

Operations like read, edit, create, delete, publish. Actions are implicit — they exist by being used in permission strings. No registration required.

Context Conditions (Type Guards)

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.

Group Conditions (Membership Gates)

Defined via the condition option of Doh.definePermissionGroup(). Determines whether a user qualifies for group membership.

  • 1-arg condition(user) — static, evaluated once at loadUserPermissionGroups() and cached
  • 2-arg condition(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']
});

Permission Strings

Format: action:context. Wildcards: *:context (any action), action:* (any context), *:* (everything). Negation prefix: ~~ (e.g., ~~delete:cloud_instance).

Runtime Context Object

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.


The Evaluation Flow

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.


Static vs Dynamic Groups

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.


Inheritance as Perspectives

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.

Example: Cloud Manager

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.

Example: Detract (Document System)

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

Negated Inheritance

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.


Last-Second Evaluation

Both gates run at Doh.permit() call time, not at definition time. This has powerful implications:

  • Group conditions can be async and query databases. The file_recipient group in user_file_storage.js queries a share database table in its condition function.
  • Context conditions validate current object state, not cached state. If an object's ownership changes between two 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.
  • The permission model adapts to current state without pre-computation, caching, or invalidation.
// 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);
  }
});

Defining Contexts and Groups

Context Patterns

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');

Group Patterns

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']
});

Configuration via Pods

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:

  • Pod definitions merge with code definitions. Pods can add 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.
  • Negated inherits (~~group_name) in the inherits array remove that group from the inheritance chain.

Migrating from Legacy Middleware

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...
});

Administrative Tools

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.


Best Practices

  1. Always use 2-arg conditions for feature modules. Modules that load after authentication must use condition(user, context) to ensure dynamic evaluation.
  2. Context conditions are type guards. Keep them pure — validate shape and properties, no side effects, no database queries.
  3. Group conditions are membership gates. These can contain business logic, database queries, and complex checks.
  4. Use Doh.checkContext() in group conditions to reuse context validation without duplicating the logic.
  5. Prefer absence of grants over ~~ negation. A user without 'delete:document' in their groups simply can't delete. Negations are for removing inherited grants.
  6. ~~ 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.
  7. Use Doh.deny(req, res) for standardized denial responses — handles both API (JSON) and HTML (redirect/403 page) automatically.
Last updated: 2/17/2026