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 staff and institution admins is enforced via
is_superuseroperationally — CampusCore never shares the bootstrap superuser creds with institutions. All institution admins areis_staff=True, is_superuser=False. - Knowledge folders can be scoped to specific roles and/or specific users in addition to the existing owner /
is_publicflags.
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. Six system roles are seeded by migration: student, faculty, staff, advisor, registrar, institution_admin. They are marked is_system=True and cannot be deleted via the admin UI. Institution-scoped roles can be added by CampusCore staff.
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 (superuser) | "Is this feature licensed/unlocked for this deployment?" |
is_enabled |
Institution (is_staff) | "Once available, has the institution turned it on?" |
enabled_for_roles (M2M) |
Institution (is_staff) | "Which roles can see it?" Required when enabled — there is no "visible to all" fallback. |
A feature 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
The institution admin MUST explicitly pick at least one role to enable a
feature. The Settings → Features UI refuses to save is_enabled=True with an
empty role list, and is_feature_enabled returns False if it ever encounters
that state (defense in depth — protects against fixtures, shell hacks, or
direct DB writes that bypass the UI validation).
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 existing source='sso' UserRoles vs target. Add new, remove stale, preserve survivors and bump their last_seen_at. 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.
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 runs in CI and on every deploy. 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 has two new M2Ms: allowed_roles and allowed_users. Plus the existing owner, is_public, and the deprecated permissions JSONField.
Access rules (owner always wins):
folder.owner_id == user.id→ allowedfolder.is_public is True→ alloweduser.id in folder.allowed_users→ alloweduser_role_slugs ∩ folder.allowed_role_slugsnon-empty → allowed- Otherwise → denied
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 joins on parent_file__content_type + parent_file__object_id__in=accessible_folder_ids (the GenericForeignKey can't be traversed in ORM lookups, so we resolve folder IDs first and __in them).
The CampusCore-vs-institution boundary¶
This is the hardest invariant in the system. Only CampusCore staff can flip FeatureState.is_available; institution admins cannot.
The marker is is_superuser. CampusCore enforces this operationally:
- The ensure_superuser management command creates the bootstrap superuser at first boot. CampusCore controls those credentials and never shares them with institutions.
- Institution IT admins are provisioned as is_staff=True, is_superuser=False. They can access Django admin and the in-app settings UI, but they cannot edit is_available.
The enforcement lives in apps/auth/admin.py::FeatureStateAdmin:
def get_readonly_fields(self, request, obj=None):
base = ("availability_updated_by", "availability_updated_at", "updated_by", "updated_at")
if request.user.is_superuser:
return base
# Institution admins cannot edit any of the CampusCore-controlled fields
return base + ("is_available",)
def has_add_permission(self, request):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
Field-level permissions are enforced by Django's readonly_fields machinery — even if an institution admin POSTs is_available=True via curl, Django silently ignores the field. This is verified by test_admin_boundary.py::test_institution_admin_cannot_flip_is_available.
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 or feature-gated view | Required role slugs / feature slug, request path |
feature_state_updated / feature_state_created |
FeatureState admin save or settings UI update |
Slug, before/after state, changed fields |
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 is preserved (deprecated) for backward compatibility.
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¶
Five 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 |
Institution admin cannot flip is_available; superuser can; add/delete are superuser-only |
test_sso_provider.py |
Existing SSO model tests, updated for the new structured claim_* columns |
Run them with docker exec campuscore_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.
- Removing the legacy
UserProfile.rolestring field. Kept for back-compat, drop in a future release.