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 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:

  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.
  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 (affected enabled features, member count) 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. Django-admin writers and the dev-only seed_keycloak_sso command are deliberately unguarded - both are operator/dev surfaces.

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; 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_idstudent_id, claim_departmentdepartment.
  • 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).

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):

  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.

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:

  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

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 on KnowledgeFolder.permissions later.
  • Hierarchical roles. Flat list is simpler and sufficient.