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 staff and institution admins is enforced via is_superuser operationally — CampusCore never shares the bootstrap superuser creds with institutions. All institution admins are is_staff=True, is_superuser=False.
  • Knowledge folders can be scoped to specific roles and/or specific users in addition to the existing owner / is_public flags.

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

  1. folder.owner_id == user.id → allowed
  2. folder.is_public is True → allowed
  3. user.id in folder.allowed_users → allowed
  4. user_role_slugs ∩ folder.allowed_role_slugs non-empty → allowed
  5. 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:

  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 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 on KnowledgeFolder.permissions later.
  • Hierarchical roles. Flat list is simpler and sufficient.
  • Removing the legacy UserProfile.role string field. Kept for back-compat, drop in a future release.