Appearance
Authorization Enforcement
Authorization is enforced in two independent layers. The portal layer is for user experience (do not show or render what you cannot use); the API layer is the security boundary (refuse the request no matter how it arrives).
Role and authorization flow
Layer 1: the portal
| Mechanism | What it does | Source |
|---|---|---|
| Edge middleware | Redirects on missing session, missing role, or wrong role for the path | cyberrange/portal/src/middleware.ts |
| Shared rule table | /admin requires admin; /instructor requires instructor or admin; segment-aware matching so /instructor-notes would not inherit /instructor rules. One function, redirectPathForRole(), makes the decision for both middleware and AuthGate: no app role goes to /no-role; a wrong role for the path, or an instructor or admin opening /dashboard, goes to the role's landing page (pull request 176) | cyberrange/portal/src/lib/routeRoles.ts |
AuthGate | Client-side mirror of the same rules while rendering; keeps the query string in callbackUrl and uses router.replace for role redirects | cyberrange/portal/src/components/auth/AuthGate.tsx |
OnboardingGate | Wraps every page; sends a student or instructor who has not completed orientation to /orientation?returnTo=..., and blocks the content (with a Retry) if the check fails. Admins are exempt. User experience only: the API does not require orientation | cyberrange/portal/src/components/auth/OnboardingGate.tsx, onboardingRedirectPath() in lib/routeRoles.ts |
| Role navigation | Sidebar shows only the role's links | cyberrange/portal/src/lib/navigation.ts |
| BFF session check | Route handlers refuse requests without a server-side session | cyberrange/portal/src/lib/apiProxy.ts |
Known limitation (documented in the code)
The middleware reads roles from the session cookie. A role removed in Keycloak is still honored by the portal until the next token refresh. The API does not have this delay beyond its 60-second cache, and the Admin page clears that cache immediately.
Layer 2: the API
| Guard | Applies to | Result on failure | Source |
|---|---|---|---|
verify_token() | every protected route | 401 (or 503 if Keycloak is down) | cyberrange/src/provisioning/auth.py |
require_app_role | whole pods_router, alerts_router and onboarding_router (router-level dependency), so a new route cannot forget it | 403 | auth.py, pods_router.py, onboarding_router.py, provision_api_fastapi.py |
require_role(["instructor", "admin"]) | instructor routes (including scoring diagnostics), staff alert routes | 403 | pods_router.py, alerts_endpoint.py |
require_role(["admin"]) | user management (including research exclusion), infra health, audit log, force-destroy | 403 | users_router.py, infra_health.py, audit_router.py, pods_router.py |
| Admin-only options on a staff route | GET /instructor/export/knowledge-gain with anonymize=false or include_excluded=true; the refusal is audited as KNOWLEDGE_GAIN_EXPORTED DENIED (pull request 272) | 403 | export_knowledge_gain() in pods_router.py |
require_owner(pod) | destroy, lab URLs, Manual Check, milestones, student alerts | 404 | auth.py |
require_owner_or_admin(pod) | lab status (also used by the terminal bridge) | 404 | auth.py |
| Owner-or-staff (reviews, images) | read a review or its screenshots | 404 | pods_router.py (_review_row_for_access) |
| Identity from token | provision, progress, reset, flag, submit review | user can only act as themselves | caller_identity() in auth.py |
| Subject from token | orientation state (/users/me/orientation), keyed by the token's sub, not a request parameter | 401 if sub is missing | onboarding_router.py |
| Shared secret | POST /internal/browser-score, POST /internal/browser-diagnostic | 401 / 503 when unset | pods_router.py |
Why 404 instead of 403 for ownership
Returning 404 when a user touches someone else's lab or review means the API never confirms that a resource with that ID exists. This prevents enumerating other students' labs by trying IDs 1 to 6.
Proving every route is classified
cyberrange/src/provisioning/test_sec01_rbac_matrix.py declares every route in a ROUTE_POLICY table as public, any application role, instructor, or admin, then checks that unauthenticated calls get 401, role-less calls get 403 and each role gets exactly the expected access. If a route is added without being classified, the test fails. A companion test, test_sec02_instructor_admin_denial.py, checks that denied admin actions leave the audit log unchanged.
Where roles are evaluated
| Role source | Portal | API |
|---|---|---|
realm_access.roles | Yes (decoded from the access token) | Yes (from introspection) |
resource_access.portal.roles (client roles) | No | Yes (union with realm roles) |
Keycloak's own roles (offline_access, uma_authorization, default-roles-cyber-range) | Ignored | Ignored |
Both sides check membership (includes("admin")), never array equality, because every token also carries Keycloak's own roles (cyberrange/docs/AUTH-03-role-contract.md).
Revocation timing
| Change | Portal pages | API |
|---|---|---|
| Admin disables a user in the Admin page | Until the next refresh (the refresh then fails and the user must sign in) | Immediately (cache cleared for that user) |
| Admin changes a role in the Admin page | At the next token refresh | Immediately |
| Change made directly in the Keycloak console | At the next token refresh | Within 60 seconds (cache expiry) |
Bypass switch
AUTH_ENABLED=false makes verify_token() return empty claims, caller_identity() trust the client-supplied student ID, and every role check pass. The API logs a CRITICAL warning at startup saying this is a full authorization bypass, intended only for local bootstrap with the API bound to a non-routable address (validate_auth_config() in auth.py). The bridge has the same switch with its own warning.
Sources
cyberrange/portal/src/middleware.ts, lib/routeRoles.ts, components/auth/AuthGate.tsx, components/auth/OnboardingGate.tsx, lib/apiProxy.ts, cyberrange/src/provisioning/auth.py, pods_router.py, onboarding_router.py, alerts_endpoint.py, users_router.py, infra_health.py, audit_router.py, test_sec01_rbac_matrix.py, test_sec02_instructor_admin_denial.py.