djangoplay-web / Apps / DjangoPlay — policyengine App
DocsDjangoPlay WebAppsDjangoPlay — policyengine App

DjangoPlay — policyengine App

policyengine is DjangoPlay's declarative, config-driven RBAC (role-based access control) engine. It has no models, no admin registrations, no URLs, and no active tests (admin.py and tests.py are bo...

13 min readApplies to v1.2.2
On this page ▾
  1. 1. Summary
  2. 2. Architecture — Two Layers That Don't Talk to Each Other at Request Time
  3. 2.1 Layer 1 — the config-driven policy matrix (build-time / assignment-time only)
  4. 2.2 Layer 2 — what actually runs on each request
  5. 2.3 The bridge — where Layer 1 becomes Layer 2 (and when)
  6. 3. Configuration Reference
  7. 3.1 ROLE_POLICIES (configs/roles.py) — only 4 roles defined
  8. 3.2 MODEL_TAGS (configs/tags.py)
  9. 3.3 ACTION_MAP (configs/permissions.py)
  10. 4. Functionality
  11. 5. Cross-App Integration Points
  12. 6. Known Gaps / Things Worth Confirming With the Team
  13. 7. Quick Reference — How Role-Based Access Actually Gets Enforced (End to End)

1. Summary

This matches the platform's own stated design principle (docs/system/identity-and-permissions.md, referenced in the platform overview doc): "No app performs ad-hoc permission checks. Permissions are centrally evaluated through policyengine." That's true in spirit — no app defines its own role logic — but the actual mechanism is more indirect than "centrally evaluated on every request" suggests: see §2 and §6, which walk through exactly how a policy defined here becomes an enforced permission, and where that chain has real gaps.


2. Architecture — Two Layers That Don't Talk to Each Other at Request Time

This is the most important thing to understand about this app. There are two genuinely separate mechanisms here, and it's easy to assume (as the docstrings half-suggest) that request-time permission checks consult the config engine directly. They don't.

2.1 Layer 1 — the config-driven policy matrix (build-time / assignment-time only)

plaintext
configs/roles.py        (ROLE_POLICIES: role → {apps: [...], actions: [...]})
configs/tags.py          (MODEL_TAGS: readonly / system_restricted / finance_strict / sso_view_only)
configs/overrides.py     (ROLE_MODEL_ACTION_OVERRIDES: role → {models: [...], actions: {...}})
configs/permissions.py   (ACTION_MAP: DRF action name → Django permission verb)
        │
        ▼
components/actions.py
  resolve_actions(role, app_label, model_name) → set of allowed actions
        │  (iterates ALL registered Django models × ALL roles in ROLE_POLICIES)
        ▼
  build_model_role_permissions() → { "app_label.model_name": { action: [role, role, ...] } }
        │
        ▼
  MODEL_ROLE_PERMISSIONS  (lazy-cached, module-level, built once per process — see §6.3)

resolve_actions applies its rules in a fixed, order-sensitive sequence (verified directly in code, matches its own docstring):

  1. No ROLE_POLICIES entry for the role → deny everything (set()).
  2. App not in the role's allowed apps list (unless the role has "*") → deny everything.
  3. Model in MODEL_TAGS["readonly"] → intersect with {"list", "retrieve"}.
  4. Model in MODEL_TAGS["sso_view_only"] → hard return {"list", "retrieve"} regardless of role or prior steps.
  5. Model in MODEL_TAGS["system_restricted"] → hard return set() — this fires even for DJGO/CEO with "*" app access and full CRUD actions; useridentity is tagged this way, so no role, including the platform's own top-level roles, gets model-level create/update/destroy/list/retrieve on UserIdentity through this engine (Django's is_superuser flag bypasses this whole system separately — see §2.2).
  6. Model in MODEL_TAGS["finance_strict"] (invoice, payment, billingschedule) and role not in {CFO, CEO, DJGO} → collapse to {"list", "retrieve"}.
  7. Three models (auditevent, industry, fileupload) are hardcoded read-only for every role, independent of the tag system.
  8. ROLE_MODEL_ACTION_OVERRIDES — additive only, unions extra actions onto specific app_label.model_name keys for a specific role. Currently only one role (SSO) has overrides defined, granting create/update on 20 specific cross-app models (invoicing, locations, teamcentral HR data, helpdesk) that the base SSO policy would otherwise only allow read access to.

2.2 Layer 2 — what actually runs on each request

plaintext
DRF View/ViewSet
        │
        ▼
ActionBasedPermission.has_permission(request, view)
   1. not authenticated → deny
   2. request.user.is_superuser → ALLOW (absolute bypass, no further checks)
   3. not is_user_active(user) → deny   (see §6.2 for what this actually checks)
   4. resolve view.get_queryset().model → app_label, model_name
   5. mapped = ACTION_MAP.get(view.action)   ← view.action, NOT the config engine
   6. mapped is None → ALLOW (fail-open fallback — see §6.1)
   7. perm = f"{app_label}.{mapped}_{model_name}"
   8. return request.user.has_perm(perm)      ← Django's native permission system

ActionBasedPermission's own docstring is explicit that it does not consult the config engine: "Uses: request.user.has_perm(). Does NOT: evaluate roles, read MODEL_ROLE_PERMISSIONS, apply overrides." So at request time, none of ROLE_POLICIES/MODEL_TAGS/ROLE_MODEL_ACTION_OVERRIDES is read. What's actually checked is whether the specific UserIdentity row has the specific Django Permission (via user_permissions or group membership) — a completely standard Django auth check.

2.3 The bridge — where Layer 1 becomes Layer 2 (and when)

policyengine.services.ssopolicies.setup_role_based_group(role_code) is what converts the config-driven matrix into real Permission/Group rows:

plaintext
setup_role_based_group(role_code):
    1. Check Redis cache (key "group_setup:{role_code}", 24h TTL) — if hit, return existing Group as-is (no rebuild)
    2. Group.objects.get_or_create(name=role_code)
    3. group.permissions.clear()                    ← always rebuilds from scratch on cache miss
    4. For every (model_key, action_map) in MODEL_ROLE_PERMISSIONS:
         - skip if app_label in a LOCAL EXCLUDED_APPS set (different from actions.py's — see §6.4)
         - look up the real Django ContentType + Permission row (must already exist, e.g. from migrations)
         - if role_code is in that action's allowed-roles list → group.permissions.add(permission)
    5. Also adds "special" non-model permissions (view_swagger, view_redoc, export_apidocs_stats)
       to the group IF role_code in {"DJGO", "CEO", "SSO"} (hardcoded, not config-driven)
    6. Cache the resulting permission list in Redis for 24h
    7. Return the Group object

This function is called from two structurally different call sites, which assign the resulting permissions to a user in two different, non-equivalent ways:

Caller What it does with the Group
utilities/ui/dashboard/services/registry_permissions_service.assign_app_registry_permissions(user, role_code) Does not add the user to the group. Instead: takes the group's permission set, filters it down to only apps present in the separately-maintained admin app registry (utilities.admin.app_registry.get_app_registry()), and assigns the filtered result directly to user.user_permissions.set(...). Comment in the code: "DO NOT assign group directly." Called from SignupFlowService.handle_manual_signup and SSOOnboardingService (both in users).
devtools/management/commands/create_superuser.py and users/views/ui/sso_onboarding.py (CustomSignupView, the allauth-driven signup path mounted at /signup/) Calls user_identity.groups.add(group) directly — the user becomes a real member of the Django Group, with no admin-registry filtering applied.

This is a real, verified inconsistency, not a hypothetical one: depending on which of DjangoPlay's three user-creation code paths a given account went through (manual signup, SSO via the allauth adapter, or SSO/direct signup via CustomSignupView), that user ends up with permissions assigned by two structurally different mechanisms — one a flattened, registry-filtered snapshot on user_permissions, the other live group membership with no registry filtering. request.user.has_perm(...) (what ActionBasedPermission actually calls) checks both sources today, so both currently work — but they can drift: a user in the group.add() path gets every permission the group has, including any app the admin registry would otherwise have excluded for the other path.


3. Configuration Reference

3.1 ROLE_POLICIES (configs/roles.py) — only 4 roles defined

plaintext
DJGO  → all apps ("*"), full CRUD
CEO   → all apps ("*"), full CRUD
CFO   → invoices, fincore only, full CRUD
SSO   → 9 apps (audit, entities, locations, teamcentral, helpdesk, invoices,
                fincore, "teams", industries, users), read-only baseline
        + 20 additive create/update overrides via ROLE_MODEL_ACTION_OVERRIDES (§2.1 step 8)

This is the single most important fact to know about this app: users/constants.py defines ~27 role codes (FMGR, AMGR, ASPC, RMGR, RSPC, ADIR, AUDT, TDIR, TAX, RDIR, RISK, IDIR, INV, CDIR, COFF, TMGR, TRAD, CFDR, CFAN, YMGR, TRY, PMGR, RPT, CMGR, CRD, MDIR, MNA — Finance Manager, AP Manager, Auditor, Tax Director, Risk Analyst, Compliance Officer, Trader, Treasury Manager, and so on) as valid employee roles. None of them have a ROLE_POLICIES entry here. Per resolve_actions step 1 (§2.1), any user whose EmploymentProfile.role.code is one of those ~27 values gets set() — zero actions, on zero models — from this engine. Two possible explanations, not distinguishable from code alone: (a) those roles are aspirational/legacy master data not yet wired into real access policy, or (b) every employee assigned one of those roles is currently locked out of everything the policy engine gates (minus whatever is_superuser/direct permission grants they might separately have). This is worth resolving directly with the team before relying on either the users.constants.ROLE_CODES list or this file as the authoritative picture of "what roles exist" — right now they materially disagree.

Also note: "teams" appears in SSO's apps list, but no installed Django app has that label (the real app is teamcentral, which is also separately listed in the same array). "teams" is dead/typo config — harmless (it just never matches any real app_label), but worth cleaning up since it reads as if SSO has access to something it doesn't.

3.2 MODEL_TAGS (configs/tags.py)

Tag Models Effect
readonly globalregion, customcountry, timezone, status, role, employmentstatus, department Force-collapses to list/retrieve for every role — these are shared reference/master-data models.
system_restricted useridentity Zero access for every role via this engine, no exceptions, checked before role overrides.
finance_strict invoice, payment, billingschedule Read-only unless role is CFO/CEO/DJGO.
sso_view_only employmentprofile, memberprofile Hard-capped to read-only for every role (including, notably, CEO/DJGO, since this check runs before the finance-strict/override logic and is an unconditional early return) — despite the tag's name, this isn't SSO-specific, it applies to all roles equally.

3.3 ACTION_MAP (configs/permissions.py)

python
{"create": "add", "update": "change", "partial_update": "change",
 "destroy": "delete", "list": "view", "retrieve": "view"}

Standard DRF ViewSet action names → Django's four built-in permission verbs (add/change/delete/view). Covers every default ModelViewSet action; does not cover custom @action-decorated methods or plain APIViews that don't set .action — see §6.1 for what happens then.


4. Functionality

Given there are no models/URLs, "functionality" here means: what this app computes, and what other apps do with that computation.

  1. Runtime permission gate — ActionBasedPermission, importable via policyengine.get_permissions_utils() (a lazy-import wrapper in __init__.py, presumably to avoid loading DRF/model registry at Django app-loading time) or directly from policyengine.components.permissions. Used as a DRF permission_classes entry on viewsets across the platform (not exhaustively re-verified per-app in this pass, but its presence as the described central mechanism is corroborated by the platform overview doc).
  2. APIDocPermission — a separate, simpler BasePermission for apidocs endpoints (Swagger/ReDoc/stats/export). Allows schema generation with no request object (request is None), otherwise requires authentication and, if the view declares a required_permission attribute, checks request.user.has_perm(required_permission) — this is how the view_swagger/view_redoc/export_apidocs_stats special permissions from ensure_special_permissions() (§4.4) actually get enforced.
  3. Role → Django Group/Permission materialization — setup_role_based_group(), the bridge described in §2.3. This is the only place MODEL_ROLE_PERMISSIONS is actually read outside of the module that builds it.
  4. Special (non-model) permission bootstrapping — ensure_special_permissions() creates three permissions under a synthetic policyengine.globalpermission content type (view_swagger, view_redoc, export_apidocs_stats) that don't correspond to any real model, so they can be assigned/checked through the same has_perm() mechanism as everything else. Hardcoded to only ever be granted to DJGO/CEO/SSO roles when setup_role_based_group runs (§2.3 step 5) — not config-driven like the rest of the engine.
  5. Shared identity/employment helpers (commons/base.py) — get_user_role, get_user_department, is_user_active, all reading through user.employmentprofile (a teamcentral model, accessed the same lazy way users.UserIdentity itself exposes it). These are the only functions re-exported at the top level of policyengine/__init__.py, suggesting they're meant as the app's small public surface for other apps to import directly rather than reaching into commons.base themselves.

5. Cross-App Integration Points

App How it touches policyengine
users SignupFlowService.handle_manual_signup, SSOOnboardingService (both link-existing and create-new paths), and views/ui/sso_onboarding.py's CustomSignupView all call into permission assignment at account-creation/SSO-linking time — via two different mechanisms, see §2.3. commons.base.get_employment_profile reads user.employmentprofile, the same property users.UserIdentity exposes.
teamcentral Indirect but foundational — every role/department/active-employment check in commons/base.py reads through EmploymentProfile (owned by teamcentral), and every MODEL_TAGS/ROLE_MODEL_ACTION_OVERRIDES entry referencing teamcentral.* models (address, department, employeetype, employmentstatus, leaveapplication, leavebalance, memberstatus, team) governs access to that app's data. policyengine never imports teamcentral models directly by class, only by string key ("teamcentral.department", etc.) — a deliberate decoupling.
utilities utilities.ui.dashboard.services.registry_permissions_service.assign_app_registry_permissions is the primary consumer of setup_role_based_group, cross-referencing its output against utilities.admin.app_registry.get_app_registry() — a third, independently-maintained app-exclusion list (REGISTRY_CONFIG["exclude_apps"]) alongside the two already inside policyengine (§6.4). utilities.services.is_admin.PlatformAdminService is imported by app_registry.py — not verified further in this pass.
devtools create_superuser management command calls setup_role_based_group("DJGO") directly and adds the new superuser to that group via .groups.add() — the direct-group-membership path, same as CustomSignupView (§2.3), not the registry-filtered path users' other signup flows use.
apidocs APIDocPermission is scoped specifically for apidocs views (swagger/redoc/stats/export), and the three special permissions ensure_special_permissions() creates exist entirely for this app's own access control.
core.utils.redis_client setup_role_based_group's 24-hour permission cache is backed directly by the shared Redis client, not Django's cache framework — worth knowing if debugging why a permission change isn't taking effect immediately (§6.3/§6.5).
Django's built-in auth app The actual enforcement mechanism (Group, Permission, ContentType, user.has_perm()) is 100% standard Django auth — policyengine is a policy-authoring and provisioning layer on top of it, not a replacement for it.

6. Known Gaps / Things Worth Confirming With the Team

  1. Which of the two "SSO" onboarding paths (registry-filtered user_permissions.set() vs. direct .groups.add()) should be authoritative, if they should converge at all?
  2. The role codes without a ROLE_POLICIES entry — legacy/aspirational, or need real policies written.
  3. Whether useridentity should ever be reachable by DJGO/CEO's "*" access — currently kept denied by design.
  4. No tests, no admin registration — tests.py and admin.py are both empty stubs. Given this app is the platform's single declared source of truth for access control, and given the concrete inconsistencies already found by static reading alone, this is probably the highest-leverage gap to close — even a handful of tests asserting resolve_actions() output for each defined role against MODEL_TAGS/overrides would have caught some of the above by construction.

7. Quick Reference — How Role-Based Access Actually Gets Enforced (End to End)

For a new engineer trying to trace "why can/can't this user do X":

  1. Find the user's EmploymentProfile.role.code (or note if they have no EmploymentProfile at all — is_user_active() returns False in that case, which means ActionBasedPermission denies them everything except what is_superuser=True would bypass; cross-check against the users app doc, §4.4 — UnifiedLoginService explicitly allows login with no EmploymentProfile, so a valid, logged-in SSO/member-only user can still be denied on every ActionBasedPermission-gated endpoint).
  2. Check whether that role code has a ROLE_POLICIES entry at all (§3.1) — if not, this engine grants nothing for them, full stop.
  3. Check MODEL_TAGS for the specific model in question (§3.3) — several tags override the role policy entirely, including for otherwise-privileged roles.
  4. Check ROLE_MODEL_ACTION_OVERRIDES for additive grants (currently SSO only).
  5. Remember that none of the above is consulted live — it only matters at the moment setup_role_based_group(role_code) was last actually run for that role, and only reaches the user if their account went through one of the two assignment mechanisms (§2.3). If a policy change was made in code but the affected user's permissions look unchanged, check both the Redis cache TTL and which of the two assignment call sites actually created that user's account.
  6. Finally, the only thing genuinely checked at request time is request.user.has_perm(f"{app_label}.{verb}_{model_name}") — a live query against whatever Permission rows ended up on that specific user (directly, or via group membership), not a live evaluation of anything in configs/.