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 |
Existing SSO config + user profile, now with structured claim_* columns and default_role FK |
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) and description (updated on drift at boot).
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).
Two 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. - 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 (affected enabled features, member count) 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. Django-admin writers and the dev-onlyseed_keycloak_ssocommand are deliberately unguarded - both are operator/dev surfaces.
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; 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.
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.
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?" 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
enabled_for_roles is non-empty AND user has at least one of those roles
A deployment-scoped feature (scope="deployment", e.g. cc_public_access) 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 (for public access, the anonymous bootstrap's public_access_enabled).
The institution admin picks at least one active role to enable a feature.
That rule has one home in code, apps/auth/services/feature_state_service.py, and the three writers that go through it are the Settings → Features API, the FeatureState Django admin form, and the boot-time sync_feature_registry seeder.
Each refuses the save in its own idiom: a 422, a field error under "Enabled for roles", and a CommandError that stops the boot.
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: 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 is picked.
The mirror-image rule refuses the other contradiction: a deployment-scoped feature cannot be given a role scope, 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 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. 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 (or legacy NULL-provider orphans, which get adopted) 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).
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).
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("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 %}
{% if_feature "advising_notes_export" %}<a href="...">Export</a>{% endif_feature %}
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="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, "advising_notes_export"):
...
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.
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.
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.
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) |
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 |
Slug, before/after state, changed fields |
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 |
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¶
Nine 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; 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_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, description drift corrected, display_name sovereign |
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.