Skip to content

RBAC & Feature Flags

CampusCore's authorization layer: SSO-driven roles, role-scoped knowledge folders, and per-deployment feature flags with a hard CampusCore-vs-institution admin boundary.

TL;DR

  • Roles drive access. They sync from the institution's IdP groups on every SSO login via RoleSyncService.
  • Feature flags are two-axis: CampusCore controls availability (licensed for this deployment), the institution controls enablement (turned on, scoped to roles).
  • The boundary between CampusCore operators and institution admins is structural: is_superuser is the only operator marker (answered everywhere via role_service.is_operator), and every operator surface (Django admin, /cc_admin/, the setup wizard) checks it. Institution admins are role-based (institution_admin) with no Django flags; is_staff grants nothing anywhere. Workspace settings, role management included, are institution self-service under auth="workspace_admin".
  • Knowledge folders carry a single visibility column - private, scoped, workspace or public - and a scoped folder names the roles and/or users that may read it.

Why

Before this layer, CampusCore had no real authorization beyond is_staff / is_superuser and owner checks. Multi-role deployments couldn't: - Sync roles from existing IdP groups (institutions had to manage users twice) - Restrict knowledge folders to specific staff (advisors, registrar, etc.) - Enable/disable features per-deployment without redeploying - Prevent institution IT from flipping CampusCore-internal flags

This doc covers what's there now and how to use it.

Models

All in apps/auth/models/, split by concern:

File Models Purpose
sso_models.py SSOProvider, UserProfile SSO config + user profile, with structured claim_* columns and a default_role FK. A row holds only its own protocol's IdP configuration - save() blanks the other block and check constraints refuse the rest
rbac_models.py Role, SSOGroupMapping, UserRole Role catalog, IdP-group → role bridge, per-user assignments
feature_models.py Feature, FeatureState Feature definition (mirrored from code registry) and per-deployment two-axis state

Role

Per-deployment role catalog. The vocabulary is registry-owned: SYSTEM_ROLE_DEFINITIONS in apps/auth/roles.py declares the system roles (student, faculty, staff, advisor, registrar, institution_admin), and the sync_role_registry command mirrors it into the DB at every boot - a role shipped in a release exists in every deployment without a migration. Rows are is_system=True and cannot be deleted via the admin UI. A role's scope is RoleScope (platform for the roles CampusCore seeds, institution for the ones a platform admin creates), constrained at the row by role_scope_is_a_known_member and carried to the SPA as that same enumeration on RoleItem.scope.

Ownership is split down the middle of the row. The registry owns slug (what code gates on), description, scope and is_system: at every boot sync_role_registry repairs description, scope and is_system on each registry slug back to the registry's values, so a system role always reads platform and system. A role an operator created under a slug the registry later ships is claimed the same way: the sync repairs its scope and is_system, and its mappings, assignments and feature scoping stay on the row. The institution owns display_name: written at creation from the registry, renameable on the Roles page, never touched by sync, and a blank rename resets the role to its default name (apps.auth.roles.default_display_name - the registry's name for system roles, the slug read as a title for institution-created ones).

Reachability and the confirmation contract

A role is reachable when at least one grant path exists: a mapping on an enabled provider, an active unexpired assignment (manual counts - institution_admin is often manual-only), or being an enabled provider's default_role. The single definition lives in apps/auth/services/role_reachability.py (assignment_paths).

Three rules hang off it:

  1. A feature cannot be newly scoped to an unreachable role - the save is a 422 naming the roles. Roles already in a scope may stay, so removing them is never blocked by their own brokenness. Boot-time feature seeding is the one deliberate exception: a default_enabled feature seeds its scope with every active role, because an all-roles scope degrades gracefully and can never produce the target failure (a scope containing only unreachable roles). Where no active role exists at all, the seeder raises and the boot stops rather than seed a feature nobody can see. The same save rule holds on every role grant - a feature scope, a connector action's scope, a connector's audience and a skill's audience - and has one home, resolve_scope_roles in apps/auth/services/scope_roles.py: an unknown slug is refused, and an inactive, excluded or unreachable slug is refused only when it is new to the grant. The same module's scope_role_catalogue builds the one role list every picker shows, as ScopeRole rows carrying slug, display_name and reachable. In the SPA the Features, Skills, Workspace Connectors and Roles reads carry that mark. Every mutation that changes a role's name or a grant path (a rename, a role grant or revoke, an SSO provider save, toggle or delete, or a mapping edit) calls invalidateRoleReads in web/src/lib/api/roleReads.ts, so a mark or a renamed role updates without a reload. It marks stale the eight reads that show a role: Roles, Features, the Skills list, Workspace Connectors, the Users list, every SSO read, Analytics, and the workspace folder dialog's role options. Its list is the one place a new read that shows a role must be added. A role deactivated after it was scoped stays in the scope, on each of these four grants. It admits nobody, because every runtime check reads active roles only, and Settings keeps listing it, muted and marked deactivated, until an administrator removes it, so reactivating the role restores the grant as it was. The catalogue holds active roles only, so a stored slug it does not carry is a deactivated one; the module's docstring states that invariant once. The Settings pickers for these four grants draw their roles with one component, RoleChecklist, which locks a role only when it is new to the grant and would be refused, so unchecking a saved role stays reversible until save. Folder scoping is outside this rule: it lists roles as ScopeRoleOption rows and keeps its own save rule.
  2. A mutation that would strand a feature-scoped role needs confirmation. The guarded self-service doors: mapping delete on the Roles page, revoking its last manual grant (User Management), and the SSO provider form's save, delete, and disable-toggle (its rules and default stop granting). Each answers 409 with code="confirmation_required" and the blast radius (the stranded roles and the enabled features each gates) in details; the client shows the message and retries the identical request with confirmed: true. Any other 409 (code="conflict": SSO-owned grant, last admin, self-lockout) is final. The Django admin's SSO provider delete is guarded too, on the same rule but in the shape that surface allows: its confirmation page states the blast radius up front, from a rolled-back dry run of the service, and a required "Delete anyway" checkbox is the operator's confirmed: true. Single and bulk delete behave alike, and the bulk one checks the selected set as one act, because two providers that each keep a role reachable can strand it together. The Role and UserRole change forms and the Role delete call the administrator rule (rule 3), not this one. No Django-admin writer calls the reachability rule, because its confirmation contract belongs to the self-service Settings surfaces, and the dev-only seed_keycloak_sso command removes no grant.
  3. The workspace must keep an administrator, and the deployment an operator. apps/auth/services/last_admin_guard.py refuses any mutation that would leave no active, unexpired institution_admin grant, and operator_guard.py refuses one that would leave no active superuser, or none who keeps a usable password. Both are final: no confirmation overrides them, because a workspace with no administrator cannot restore one and a deployment with no operator cannot reach /cc_admin/. Every door that removes a grant calls the administrator rule: the SPA's manual revoke, the SSO provider delete on the SPA and in the Django admin, the UserRole admin delete, the Role admin delete (grants cascade on role), and the User admin delete. Each rule runs before its mutation, inside the mutation's transaction, and locks the whole set it counts so concurrent removals serialize rather than deadlock. The SSO role sync is the one remover that asks the rule and does not refuse on its answer, because the IdP is the source of truth for the grants it owns and a login that quietly kept a revoked administrator would be the worse outcome. When a login's diff empties the set it deletes the grant, writes a role_sync_removed_last_admin audit row in the same transaction, and posts to the workflow Slack channel once that transaction commits. A login that leaves another administrator in place, or finds none to begin with, does none of that. Four Django-admin forms reach the same two states without deleting anything, and each one asks its rule before the save. The User change form asks the operator rule when is_superuser or is_active flips off, the Role change form asks the administrator rule when is_active or slug changes on institution_admin, and the UserRole change form asks it when expires_at or role changes on an administrator's grant. The operator rule counts password holders as well, so the same flip, or the User admin delete, is refused when it removes the last active superuser with a usable password while an SSO-only superuser remains. The User password form asks the same password count when it would disable password authentication, which the next paragraph describes. A refused save is an error under the field the operator changed, with the guard's own sentence, and nothing is written - no row change, no admin LogEntry, no audit row. Each guard lives on its form, so a list_editable on any of the three change forms would route around it. Both rules guard a transition, not a state: an already-empty set is not the mutation's doing and is not refused, so a fresh deployment can still be administered into shape. The User admin permits no deletion at all, by design: a user row is the subject of its audit history, the owner of its knowledge folders and the holder of its grants, so deprovisioning is deactivation, and the guards sit behind that refusal. The admin's password page asks the same password count before it disables password authentication, because an operator with no password and no SSO cannot reach /cc_admin/. It asks that count alone, for the one transition the flag doors cannot see: a disabled password leaves the account's flags standing, so after one of two superusers loses a password both still count as operators by their flags, and only the password count refuses the second disable. Nothing overrides the rule, an enabled SSO provider included, because the app cannot tell that the identity provider still knows the operator; an operator who wants a password-free deployment grants superuser to a second person, gives them a password, and only then disables their own. The refusal is a field error under usable_password, and nothing is written.

Shipping a new role (developer recipe)

  1. Add the slug to SystemRole and a RoleDefinition to SYSTEM_ROLE_DEFINITIONS in apps/auth/roles.py - the two are cross-checked at import.
  2. Gate the feature code on the slug (user_has_role, feature scoping, folder scoping).
  3. Nothing else. The next boot's sync_role_registry creates the row in every deployment (or, when an institution already made a role under that slug, repairs its description, scope and is_system and keeps its grants); the Roles page shows it as Unassigned until each institution maps or grants it, and a feature cannot be newly scoped to it until someone can actually hold it. The Django admin shows a system role's slug, scope and system flag read-only, because the registry owns those three columns and an edit here would strand the grants on a renamed row.

SSOGroupMapping

Translates an IdP group claim value to a CampusCore Role. Supports exact and iexact match modes, supports DN strings up to 512 chars. Multiple mappings can resolve to the same role; one IdP group can grant multiple roles.

UserRole

A per-user role assignment. Carries source (sso or manual), assigned_at (immutable), last_seen_at (bumped on every resync), and an optional expires_at for time-bounded access. Manual grants survive SSO resyncs - only source='sso' rows are touched by RoleSyncService.

source decides which companion columns the row may carry, and check constraints hold the rule. An sso row names the provider whose mapping granted it (source_sso_provider) and no granter; a manual row names no provider. A manual row may have no granter: the pre-RBAC backfill wrote grants without one, and deleting a former admin's account clears the column. Deleting a provider deletes the grants it made (on_delete=CASCADE); the holder's next sign-in through a provider that still maps the role writes them back, and the Settings SSO page's delete dialog says how many people that affects before the admin confirms.

Feature / FeatureState

Two-axis feature flag state per deployment:

Field Controlled by Meaning
is_available CampusCore operator (superuser, via Django admin) "Is this feature licensed/unlocked for this deployment?"
is_enabled Institution (workspace admin, via Settings → Features) "Once available, has the institution turned it on?"
enabled_for_roles (M2M) Institution (workspace admin, via Settings → Features) "Which roles can see it?"
enabled_for_users (M2M) Institution (workspace admin, via Settings → Features) "Which people can see it whatever their roles?" At least one role or one person is required when enabled - there is no "visible to all" fallback.

A user-scoped feature (FeatureDefinition.scope="user", the default) is visible to a user iff:

is_available AND is_enabled AND
(user holds at least one active role in enabled_for_roles
 OR user is an active account named in enabled_for_users)

The two scopes are a union: a named person sees the feature with no role at all, and a role-holder sees it without being named. A person named on a row keeps their row when their account is deactivated - the evaluators skip inactive accounts, and Settings keeps listing them, marked deactivated, until an administrator removes them - and loses it when the account is deleted. A workspace folder's named people are listed the same way, marked deactivated in the folder dialog until an administrator removes them.

A deployment-scoped feature (scope="deployment") gates a workspace-wide surface with no per-person answer: it is on iff is_available AND is_enabled, for any caller including an anonymous one, carries no role scope, and never appears in the per-user bootstrap.features list - it gets its own wire when a client needs one. Three ship today: cc_public_access (the anonymous bootstrap's public_access_enabled), cc_website_widget, and cc_agent_skills, which has no client wire at all because its one reader is the agent's skill catalog loader (agent-skills.md).

Connector carries the same two-axis split - is_available flipped by an operator in Django admin, is_enabled by a workspace admin in Settings -> Workspace Connectors. It has no enablement scope of its own, but it does carry an audience, restricted_to_roles beside restricted_to_users, which narrows an offered connector to the holders of listed roles and to named people; that is a different question from enablement and is not under the rule below, though it reads as the same union. See connectors.md and connectors.md.

The institution admin picks at least one active role or one active person to enable a feature. That rule has one home in code, apps/auth/services/enablement_service.py, and seven writers go through it. Three are on the feature side: the Settings → Features API, the FeatureState Django admin form, and the boot-time sync_feature_registry seeder. Four are on the connector side, where ConnectorActionPolicy carries the same switch-and-scope pair: action_policy_save, action_policies_bulk_save, the boot-time sync_action_policies, and the dev-only seed_canvas_connector (see connectors.md). Each refuses the save in its own idiom: the APIs return a 422, the admin form attaches a field error under "Enabled for roles", and sync_feature_registry raises a CommandError that stops the boot. The people half of the scope goes through the same seven writers: the two Settings APIs take user_ids beside role_slugs, the admin form offers the people picker beside the roles, and the syncs and seeders pass an empty people list. sync_action_policies can never be refused, because it derives the switch from the same role list it scopes the row to. Writes that bypass the service - a fixture, a shell session, a hand-written migration - are not blocked, and is_feature_enabled returns False for an enabled feature whose active scope is empty, which is the defense in depth for those. Deactivating or deleting the last role in a feature's scope is the same case, as is deactivating its last named person: the row stays enabled, users stop seeing the feature, and the next save through the Settings UI refuses to keep it on until a role or a person is picked. apps/auth/tests/test_enablement_writers.py records every function in production code that writes the pair and fails when a new one appears, so a writer outside the service is a deliberate edit that review sees.

updated_by and updated_at on FeatureState and ConnectorActionPolicy are stamped together only when a person edits the switch or the scope. A boot sync or a seeder leaves them alone, so a NULL updated_at means no person has edited the row, and the stamp always names the last person even when the system wrote the row later. The database refuses updated_by without updated_at; the reverse shape, a time with no person, is what deleting that person's account leaves behind.

The mirror-image rule refuses the other contradiction: a deployment-scoped feature cannot be scoped to roles or people, because it has no per-person answer to give and no field on the wire to carry one. check_enablement raises for every writer, and the boot-time sync_feature_registry clears a stray role or person scope off any deployment-scoped row it finds.

Feature rows mirror the in-process registry - they're populated by the sync_feature_registry management command, not by hand-written migrations.

Architecture in layers

Layer 1: Role ingestion from SSO

apps/auth/services/role_sync_service.py is wired up via the user_logged_in allauth signal in apps/auth/signals.py.

Why user_logged_in and not pre_social_login? At pre_social_login time the User row doesn't yet have a PK for first-time signups, so role assignment would silently no-op. The user_logged_in signal fires after commit for both first-time and returning logins.

Algorithm: 1. Read sso_provider.claim_groups. If blank → no-op (role sync disabled for this provider). 2. Extract group values from the assertion (handles four IdP shapes - see normalizer below). 3. Distinguish three failure cases: - No claim configured → no-op - Empty groups in assertion → do NOT wipe existing SSO roles (transient IdP bug protection) - Groups present but no SSOGroupMapping matches → fall back to default_role if set 4. Lock the user row with select_for_update() to prevent races from concurrent logins. 5. Ask removes_last_admin whether the rows this diff removes are the whole administrator set, then diff source='sso' UserRoles vs target, provider-scoped with last-confirmer ownership. A role two providers grant is one row (unique_together (user, role)); additions are computed against the user's SSO rows across ALL providers (so an existing row is never re-reported as added), rows in the login's target set get last_seen_at bumped and source_sso_provider re-stamped to the syncing provider, and only rows the syncing provider owns are deletion candidates. Two providers therefore never strip each other's grants; single-provider deployments behave exactly as before. Manual grants are never touched. 6. Cycle the session key on role change. 7. Audit a single role_sync event with SSOGroupMapping.id values in details, NEVER raw group strings (avoids PII leak). A diff that emptied the administrator set writes a second, critical role_sync_removed_last_admin row inside the removal's transaction and announces it in Slack after the commit.

Failure isolation: any exception in the receiver is caught and logged. A broken role sync must never lock users out of CampusCore.

Profile attribute sync

apps/auth/services/profile_sync_service.py rides the same user_logged_in hook as role sync, in its own receiver, and copies the provider's mapped profile claims onto UserProfile on every SSO login:

  • claim_student_id → student_id, claim_department → department.
  • Login provenance is stamped: sso_provider (the protocol) and external_id (the SocialAccount uid allauth linked by).

It follows role sync's no-wipe rule - a configured-but-absent claim never blanks a stored value - and its isolation contract: exceptions are logged and the login proceeds. Log lines carry field names only, never claim values (student PII).

A login that finds no UserProfile row logs a WARNING and syncs nothing. Every user gets a profile from the post_save receiver, so a missing row means that receiver did not fire, and login-path code must not start creating rows a second writer would then own.

The shared claim-payload primitives (the userinfo/id_token envelope vs flat-SAML walk, and provider resolution) live in apps/auth/services/sso_claims.py, used by the adapter, role sync, and profile sync alike.

Group claim normalization

Four supported IdP claim shapes, all normalized by normalize_group_claim_value:

Shape Example Notes
List of strings ["advisors", "staff"] Most OIDC providers
Comma-separated string "advisors, staff" Legacy SAML - split unless = is present (DN protection)
List of dicts with value [{"value": "advisors"}] Azure AD's groups claim
LDAP DN string "CN=Advisors,OU=Staff,DC=vsu,DC=edu" One group, NOT split on commas

The DN-vs-comma disambiguation is critical: if any segment contains =, the string is treated as a single LDAP DN. Otherwise it's split on commas. See test cases in test_role_sync_service.py.

Layer 2: Authorization primitives

apps/auth/services/role_service.py:

def user_has_role(user, *role_slugs: str, request=None) -> bool      # ANY match
def user_has_all_roles(user, *role_slugs: str, request=None) -> bool # ALL match
def get_user_role_slugs(user, request=None) -> frozenset[str]        # request-cached
async def aget_user_role_slugs(user, request=None) -> frozenset[str] # ASGI-safe

All role lookups respect Role.is_active=True and UserRole.expires_at > now().

Per-request cache lives in apps/auth/context.py: RBACContext is attached to request.rbac by campus_core.middleware.RBACContextMiddleware. The cache is lazy - the DB is only hit on first access. Holds an immutable frozenset[str] of role slugs, safe across async tasks in streaming chat.

Layer 3: Decorators and template tags

In campus_core/decorators.py, all HTMX-aware (return HX-Redirect for HTMX requests, 403 otherwise):

@roles_required("advisor", "registrar")    # ANY of these
@roles_required_all("staff", "faculty")    # ALL of these
@feature_required("cc_advising_notes_export") # feature flag gate

Permission denials are audited via audit_service.log_from_request with action="permission_denied".

In apps/auth/templatetags/auth_tags.py:

{% load auth_tags %}
{% if_has_role "advisor" %}<button>Edit notes</button>{% endif_has_role %}

Layer 4: Feature flags

Source of truth is the in-process registry in apps/auth/features.py. Developers register a flag in code:

from apps.auth.features import register_feature

register_feature(
    slug="cc_advising_notes_export",
    name="Advising Notes Export",
    description="Allow advisors to export advising notes as PDF.",
    default_enabled=False,
)

Then check it:

from apps.auth.services.feature_service import is_feature_enabled
if is_feature_enabled(request.user, "cc_advising_notes_export"):
    ...

The slug is typed: is_feature_enabled and @feature_required take FeatureSlug, the union of the UserFeatureSlug and DeploymentFeatureSlug aliases that list the shipped flags of each scope, so a misspelling is a type error rather than a gate that never opens. cc_advising_notes_export above is an invented example rather than a member of either alias; a real gate names one of the shipped slugs, and adding a flag means adding it to its alias. register_feature keeps a str slug, because tests register throwaway flags through it and the registry answers for those too.

The Feature DB rows are populated by the sync_feature_registry management command, which every web container runs at boot via run_boot_sequence. This means new flags show up in the admin UI without writing migrations. A renamed flag declares its old slug with renamed_from= so the sync re-keys each deployment's row instead of replacing it; see Renaming a flag.

is_feature_enabled MUST NEVER write an audit log entry. Checks happen on every page render and every gated UI element - logging would swamp the DB. Only writes to FeatureState (in admin / settings UI) get audited, and that happens at the write site. This invariant is enforced by test_feature_service.py::test_is_feature_enabled_never_writes_audit_log.

For developers: the end-to-end recipe for adding a new feature flag is in Adding a Feature Flag. For institution admins: see Managing Features.

Layer 5: Knowledge folder scoping

KnowledgeFolder answers "who may read this" with one column, visibility, plus the owner and the two M2Ms allowed_roles and allowed_users that a scoped folder names. Two database check constraints keep visibility from contradicting kind: a personal folder must be private, and a workspace folder must not be.

Access rules (owner always wins):

  1. folder.owner_id == user.id → allowed
  2. folder.visibility is workspace or public → allowed
  3. folder.visibility == 'scoped' and user.id in folder.allowed_users → allowed
  4. folder.visibility == 'scoped' and user_role_slugs ∩ folder.allowed_role_slugs non-empty → allowed
  5. Otherwise → denied

A private folder is owner-only: rules 3 and 4 match scoped folders only, so a stray legacy share row on a personal folder grants nobody access.

The folder's own wire shape, FolderSummary, carries each named person labelled through the directory's ScopePerson - the id, the name, the email and whether the account is still active - so the admin dialog names a chip from the folder it was handed rather than asking a second endpoint who these people are.

Implemented in KnowledgeFolderService.user_can_access_folder and used by get_folder, list_folders, get_folder_files_qs. The vector store retrieval filter in document_chunk_store.py::_apply_access_control calls KnowledgeFolderService.get_accessible_folder_ids(user) once per request and filters on the chunk-local knowledge_folder_id__in=accessible_folder_ids (the scope columns are denormalized onto DocumentChunk exactly so the retrieval filter needs no join).

The CampusCore-vs-institution boundary

This is the hardest invariant in the system. Only CampusCore operators can flip FeatureState.is_available; institutions cannot.

The marker is is_superuser, and the boundary is structural, not operational: - The ensure_superuser management command creates the bootstrap superuser at first boot. CampusCore controls those credentials and never shares them with institutions. - Institution admins are regular accounts holding the institution_admin role (SSO group mapping or a manual grant). They administer the workspace entirely through the SPA's Settings surface; they have no Django flags and no path into Django admin or /cc_admin/. - is_staff grants nothing. The Django admin runs on OperatorAdminSite (campus_core/admin_site.py), whose has_permission and login form both check is_superuser; every /cc_admin/ tool is behind @htmx_superuser_required.

The enforcement for feature state: the whole FeatureStateAdmin answers only to superusers, and the institution's write path is workspace_features_api (Settings → Features), which can toggle is_enabled and the role scope but re-checks is_available server-side and can never change it.

No one deletes a FeatureState row from the admin, the operator included: FeatureStateAdmin.has_delete_permission returns False, so the change page has no Delete link, the changelist has no delete action, and the delete URL answers 403. A delete would not last anyway, because sync_feature_registry seeds a state for every registry feature that lacks one at the next boot, with the registry's defaults in place of the deployment's own settings. A retired feature leaves through manage.py sync_feature_registry --prune-orphans, which deletes the Feature row of every slug no longer in the registry and, by cascade, its FeatureState.

This is verified by test_admin_boundary.py (a staff-flagged non-superuser reaches nothing in the FeatureState admin) and by test_operator_gate_sweep.py, which sweeps every /cc_admin/ URL for the same actor.

Audit events

All RBAC events go through the existing apps/main_app/services/audit_service.py:

Action Triggered by Carries
role_sync Every SSO login that changes roles Added/removed role IDs, matched mapping IDs (NOT raw group strings)
role_sync_removed_last_admin An SSO login whose diff removed the workspace's last administrator grant Provider id, removed role ids, the ids of this provider's mappings that granted the administrator role, matched mapping ids (never group strings)
permission_denied A user tries to access a role-gated, feature-gated, or operator-gated view (reasons: missing_role, missing_required_roles, feature_disabled, superuser_required) Required role slugs / feature slug / flag, request path
feature_state_updated / feature_state_created FeatureState admin save or settings UI update, written by log_critical inside the save's transaction so a change never lands without its row Slug, the state after the save, and for the admin save the changed fields; the settings update carries role_slugs and user_ids (ids only, never emails)
connector_audience_updated Settings -> Workspace Connectors, the Audience tab of a connector's drawer Connector slug and the replacement role_slugs and user_ids (slugs and ids only)
role_renamed Roles page rename Slug, old and new display names
role_mapping_created / role_mapping_updated / role_mapping_deleted Roles page mapping CRUD Mapping id, provider id, group value
role_assigned_manual / role_revoked_manual User Management grant/revoke Target user id, role slug and id
user_admin_created / user_admin_updated / user_admin_deleted / user_admin_password_changed A User edit in the Django admin, including the password-change view Changed field names with before/after values, never password material; the operator on user, the subject in resource_id

The Roles page writes use the critical tier (log_critical, synchronous and in-transaction) - the same class as the sso_provider_* events.

Explicitly not logged: - is_feature_enabled checks (would swamp the DB - see Layer 4) - Raw IdP group claim strings (PII / compliance - only mapping IDs go in)

Migration / rollout

The single migration that adds all this is apps/auth/migrations/0003_rbac_and_features.py. It does three things the auto-generator can't:

  1. Copies the existing attribute_mapping JSONField into the new claim_* columns BEFORE the old field is removed. Without this, customer SSO config would be wiped on deploy.
  2. Seeds the system roles immediately after the Role model is created so subsequent FK additions can rely on them existing.
  3. Backfills UserRole rows from the legacy UserProfile.role string field so existing users have a baseline role until their next SSO login resyncs them. The legacy field has since been removed; RBAC UserRole rows are the only role storage.

The risk window between rollout and a user's next SSO login is bounded by their session lifetime. Document this and roll out during a low-activity window.

Testing

Fourteen test files cover the layer:

File Coverage
test_role_sync_service.py All four IdP claim shapes; three failure cases; manual grant preservation; diff semantics; default-role fallback
test_feature_service.py Two-axis truth table; fail-closed for unknown/deprecated/unauthenticated; a database fault raises and is never cached as off; invariant: no audit log entries from checks
test_rbac_decorators.py @roles_required, @roles_required_all, @feature_required; HTMX vs non-HTMX; permission denied audit
test_admin_boundary.py The FeatureState admin answers only to superusers; a staff-flagged non-superuser reaches nothing
test_sso_provider.py Existing SSO model tests, updated for the new structured claim_* columns
test_role_reachability.py The three grant paths; disabled providers and expired grants don't count; blast radius names only enabled features
test_last_admin_guard.py Which rows count as an administrator (expiry and role activity), which transitions are refused, and that a non-admin removal never takes the lock
test_role_sync_last_admin.py The one login that empties the administrator set: the grant goes, the critical row is written with ids only, one Slack message names no person, and the three logins that must stay silent do
test_guarded_admin_deletes.py The Django admin's guarded deletes over their real routes: the acknowledgement checkbox, the no-form refusal, forged POSTs, and bulk delete
test_admin_lockout_forms.py The Django admin's change forms and the User password page over their real routes: which saves are refused and under which field, that a refused save writes nothing, and that a system role's registry columns are read-only
test_role_catalog_service.py Renames (blank resets a system role), mapping CRUD, and every guard door in the SSO provider service
test_sync_role_registry.py Registry ownership: creates from the registry, repairs description, scope and is_system drift, display_name sovereign
test_feature_registry_rename.py renamed_from validation: a retired slug cannot be the flag itself, a live flag, or claimed twice
test_workspace_roles_api.py The Roles API: authz on every route, round-trips with audit rows, the confirmation_required contract, honest sso_sync_active

Run them with docker compose exec -T web pytest apps/auth/tests/.

What's deliberately out of scope (v1)

  • SCIM auto-deprovisioning. "User logs in to lose their role" is good enough until v2.
  • Percentage rollouts on feature flags. Add later if needed.
  • Negative permissions (denied_role_slugs). Add as a JSON key on KnowledgeFolder.permissions later.
  • Hierarchical roles. Flat list is simpler and sufficient.