--- since: 1.2.1 --- # DjangoPlay — `policyengine` App ## 1. Summary `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 both stub files with only a comment) — its entire footprint is: a small set of Python dict constants describing role → app/model/action policy (`configs/`), pure functions that resolve those constants into a permissions matrix (`components/actions.py`), a DRF `BasePermission` class that gates requests using **Django's native permission system** rather than the config engine directly (`components/permissions.py`), and a service (`services/ssopolicies.py`) that's the actual bridge between the two — it materializes the config-driven policy into real Django `Group`/`Permission` rows, which is what ends up enforced at request time. 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) ``` 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 ``` 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: ``` 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 ``` 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 `APIView`s 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/`.