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_superuseris the only operator marker (answered everywhere viarole_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_staffgrants nothing anywhere. Workspace settings, role management included, are institution self-service underauth="workspace_admin". - Knowledge folders carry a single
visibilitycolumn -private,scoped,workspaceorpublic- and ascopedfolder 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:
- 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_enabledfeature 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_rolesinapps/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'sscope_role_cataloguebuilds the one role list every picker shows, asScopeRolerows carryingslug,display_nameandreachable. 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) callsinvalidateRoleReadsinweb/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 asScopeRoleOptionrows and keeps its own save rule. - 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) indetails; the client shows the message and retries the identical request withconfirmed: 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'sconfirmed: 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. TheRoleandUserRolechange forms and theRoledelete 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-onlyseed_keycloak_ssocommand removes no grant. - The workspace must keep an administrator, and the deployment an operator.
apps/auth/services/last_admin_guard.pyrefuses any mutation that would leave no active, unexpiredinstitution_admingrant, andoperator_guard.pyrefuses 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, theUserRoleadmin delete, theRoleadmin delete (grants cascade onrole), and theUseradmin 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 arole_sync_removed_last_adminaudit 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. TheUserchange form asks the operator rule whenis_superuseroris_activeflips off, theRolechange form asks the administrator rule whenis_activeorslugchanges oninstitution_admin, and theUserRolechange form asks it whenexpires_atorrolechanges on an administrator's grant. The operator rule counts password holders as well, so the same flip, or theUseradmin delete, is refused when it removes the last active superuser with a usable password while an SSO-only superuser remains. TheUserpassword 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 adminLogEntry, no audit row. Each guard lives on its form, so alist_editableon 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. TheUseradmin 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 underusable_password, and nothing is written.
Shipping a new role (developer recipe)¶
- Add the slug to
SystemRoleand aRoleDefinitiontoSYSTEM_ROLE_DEFINITIONSinapps/auth/roles.py- the two are cross-checked at import. - Gate the feature code on the slug (
user_has_role, feature scoping, folder scoping). - Nothing else. The next boot's
sync_role_registrycreates the row in every deployment (or, when an institution already made a role under that slug, repairs itsdescription,scopeandis_systemand 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) andexternal_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:
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):
folder.owner_id == user.id→ allowedfolder.visibilityisworkspaceorpublic→ allowedfolder.visibility == 'scoped'anduser.id in folder.allowed_users→ allowedfolder.visibility == 'scoped'anduser_role_slugs ∩ folder.allowed_role_slugsnon-empty → allowed- 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:
- Copies the existing
attribute_mappingJSONField into the newclaim_*columns BEFORE the old field is removed. Without this, customer SSO config would be wiped on deploy. - Seeds the system roles immediately after the
Rolemodel is created so subsequent FK additions can rely on them existing. - Backfills
UserRolerows from the legacyUserProfile.rolestring field so existing users have a baseline role until their next SSO login resyncs them. The legacy field has since been removed; RBACUserRolerows 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 onKnowledgeFolder.permissionslater. - Hierarchical roles. Flat list is simpler and sufficient.