# RBAC Policy Configuration # # Defines access rules for the website. Identity (who the user is) # comes from JWT claims or session; this file defines what each group # is allowed to access. # # Group names must match the role strings derived from JWT: # Role::Admin → "admin" # Role::Moderator → "moderator" # Role::User → "user" # Role::Guest → "guest" # Role::Custom(s) → s # # Validate: nickel export rbac.ncl let C = import "rbac/contracts.ncl" in let Roles = import "config/roles.ncl" in # Derive valid role names from the canonical registry. # Every group name and every parent_groups reference must be present here. let valid_role_names = Roles.roles |> std.array.map (fun r => r.name) in # Cross-validates that all group names and parent_groups in this file # resolve to a role defined in site/config/roles.ncl. let RoleNamesConsistent = std.contract.custom (fun _label value => let unknown_groups = value.groups |> std.array.filter (fun g => !(std.array.elem g.name valid_role_names)) |> std.array.map (fun g => g.name) in let unknown_parents = value.groups |> std.array.flat_map (fun g => g.parent_groups |> std.array.filter (fun p => !(std.array.elem p valid_role_names)) ) in let all_invalid = unknown_groups @ unknown_parents in if all_invalid == [] then 'Ok value else 'Error { message = "rbac.ncl references roles not defined in site/config/roles.ncl: " ++ std.string.join ", " all_invalid, } ) in # Reusable effect sets — compose with array concatenation (@) let E = { redirect_login = [{ type = "redirect", to = "/login?return={request.path}" }], audit_deny = [{ type = "audit_log", event = "access_denied", level = "warn" }], audit_allow = [{ type = "audit_log", event = "access_granted", level = "info" }], api_forbidden = [{ type = "response", status = 403, body.error = "Forbidden" }], log_warn = [{ type = "log", level = "warn", message = "Denied: {request.path}" }], security_alert = [{ type = "notify", channel = "security", message = "Admin access: {request.path} by {user.email}", }], } in { groups = [ { name = "admin", parent_groups = [], permissions = [ { resource = "endpoint", pattern = "/api/*", outcome = "allow", methods = [], effects = E.audit_allow, }, { resource = "page", pattern = "/*", outcome = "allow", methods = [], effects = [], }, ], }, { name = "supervisor", parent_groups = ["user"], permissions = [ # Can broadcast notifications (belt-and-suspenders alongside Axum handler check). { resource = "endpoint", pattern = "/api/notify", outcome = "allow", methods = ["POST"], effects = E.audit_allow, }, # Service token management is admin-only — explicitly deny. { resource = "endpoint", pattern = "/api/server_list_service_tokens*", outcome = "deny", methods = ["POST"], effects = E.audit_deny @ E.api_forbidden }, { resource = "endpoint", pattern = "/api/server_create_service_token*", outcome = "deny", methods = ["POST"], effects = E.audit_deny @ E.api_forbidden }, { resource = "endpoint", pattern = "/api/server_revoke_service_token*", outcome = "deny", methods = ["POST"], effects = E.audit_deny @ E.api_forbidden }, { resource = "endpoint", pattern = "/api/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.api_forbidden, }, ], }, { name = "moderator", parent_groups = ["user"], permissions = [ { resource = "endpoint", pattern = "/api/content/*", outcome = "allow", methods = ["GET", "POST", "PUT"], effects = E.audit_allow, }, { resource = "endpoint", pattern = "/api/notify", outcome = "allow", methods = ["POST"], effects = E.audit_allow, }, { resource = "endpoint", pattern = "/api/admin/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.api_forbidden, }, { resource = "page", pattern = "/admin/content/*", outcome = "allow", methods = [], effects = [], }, ], }, { name = "user", parent_groups = [], permissions = [ { resource = "page", pattern = "/*", outcome = "allow", methods = [], effects = [], }, # Allow read access to public content endpoints (blog, recipes, etc.) { resource = "endpoint", pattern = "/api/content-render/*", outcome = "allow", methods = ["GET"], effects = [], }, { resource = "endpoint", pattern = "/api/content-index/*", outcome = "allow", methods = ["GET"], effects = [], }, # Public assets — must be explicitly allowed for authenticated users # (unauthenticated_allow only applies to anonymous requests) { resource = "asset", pattern = "/*", outcome = "allow", methods = [], effects = [] }, # WebSocket — must be allowed explicitly; unauthenticated_allow doesn't apply to authed users { resource = "endpoint", pattern = "/api/ws", outcome = "allow", methods = ["GET"], effects = [] }, # Authenticated user endpoints (Leptos server functions — hash suffix varies per build) { resource = "endpoint", pattern = "/api/track_page_view*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_send_contact_form*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_get_bookmarks*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_add_bookmark*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_remove_bookmark*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_delete_account*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_logout*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_logout_all*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_update_display_name*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_refresh_token*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_get_session_user*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_get_messages*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_mark_message_read*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_get_notes*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_create_note*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_update_note*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_delete_note*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_get_resources*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_delete_message*", outcome = "allow", methods = ["POST"], effects = [] }, # Activities — authenticated access (download gated by handler logic) { resource = "endpoint", pattern = "/api/server_check_activity_access*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_record_activity_completion*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_submit_internal_questionnaire*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/activities/download/*", outcome = "allow", methods = ["GET"], effects = [] }, { resource = "endpoint", pattern = "/api/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.api_forbidden, }, ], }, ], user_overrides = [ # Example: block a specific user and redirect to login # { # id = "blocked@ontoref.dev", # permissions = [{ # resource = "page", # pattern = "/*", # outcome = "deny", # methods = [], # effects = E.audit_deny @ E.security_alert @ E.redirect_login, # }], # }, ], defaults = { unauthenticated_allow = [ # Protected paths evaluated first — first match wins { resource = "page", pattern = "/admin/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.redirect_login }, # Recipes require authentication (EN + ES routes) { resource = "page", pattern = "/recipes", outcome = "deny", methods = [], effects = E.audit_deny @ E.redirect_login }, { resource = "page", pattern = "/recipes/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.redirect_login }, { resource = "page", pattern = "/recetas", outcome = "deny", methods = [], effects = E.audit_deny @ E.redirect_login }, { resource = "page", pattern = "/recetas/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.redirect_login }, # Public pages { resource = "page", pattern = "/login", outcome = "allow", methods = [], effects = [] }, { resource = "page", pattern = "/register", outcome = "allow", methods = [], effects = [] }, { resource = "page", pattern = "/*", outcome = "allow", methods = [], effects = [] }, # Recipes API endpoints also require authentication { resource = "endpoint", pattern = "/api/content-render/recipes/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.api_forbidden }, { resource = "endpoint", pattern = "/api/content-index/recipes/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.api_forbidden }, # Public endpoints and assets { resource = "endpoint", pattern = "/api/health", outcome = "allow", methods = [], effects = [] }, { resource = "endpoint", pattern = "/api/auth/*", outcome = "allow", methods = [], effects = [] }, { resource = "endpoint", pattern = "/api/ws", outcome = "allow", methods = ["GET"], effects = [] }, # HTMX public endpoints (htmx-ssr rendering profile, ADR-005). User- # scoped endpoints under /api/htmx/user/* are intentionally NOT here; # they fall through to the auth required deny. { resource = "endpoint", pattern = "/api/htmx/auth/*", outcome = "allow", methods = [], effects = [] }, { resource = "endpoint", pattern = "/api/htmx/theme/*", outcome = "allow", methods = [], effects = [] }, { resource = "endpoint", pattern = "/api/htmx/lang/*", outcome = "allow", methods = [], effects = [] }, { resource = "endpoint", pattern = "/api/htmx/cookies/*", outcome = "allow", methods = [], effects = [] }, { resource = "endpoint", pattern = "/api/htmx/validate/*", outcome = "allow", methods = [], effects = [] }, { resource = "endpoint", pattern = "/api/htmx/content/*", outcome = "allow", methods = ["GET"], effects = [] }, { resource = "endpoint", pattern = "/api/htmx/notifications/*", outcome = "allow", methods = ["GET"], effects = [] }, # Leptos server functions for OTP auth flow — hash suffix varies per build, # glob * matches the numeric hash without requiring a path separator. { resource = "endpoint", pattern = "/api/server_request_otp*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_verify_otp*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_accept_gdpr*", outcome = "allow", methods = ["POST"], effects = [] }, # Session hydration — called by WASM on every new tab to restore AuthState from existing cookie { resource = "endpoint", pattern = "/api/server_get_session_user*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/track_page_view*", outcome = "allow", methods = ["POST"], effects = [] }, # Contact/work-request form — public submission, no auth required { resource = "endpoint", pattern = "/api/server_send_contact_form*", outcome = "allow", methods = ["POST"], effects = [] }, { resource = "endpoint", pattern = "/api/server_get_bookmarks*",outcome = "allow", methods = ["POST"], effects = [] }, # Activities — check access is a read-only status fn, public { resource = "endpoint", pattern = "/api/server_check_activity_access*", outcome = "allow", methods = ["POST"], effects = [] }, # Activity callback is HMAC-verified, no session required { resource = "endpoint", pattern = "/api/activities/callback", outcome = "allow", methods = ["GET"], effects = [] }, # Slidev static builds — public, no auth { resource = "asset", pattern = "/slides/*", outcome = "allow", methods = [], effects = [] }, { resource = "endpoint", pattern = "/api/content-render/*", outcome = "allow", methods = ["GET"], effects = [] }, { resource = "endpoint", pattern = "/api/content-index/*", outcome = "allow", methods = ["GET"], effects = [] }, { resource = "asset", pattern = "/*", outcome = "allow", methods = [], effects = [] }, ], unauthenticated_deny = [], authenticated_fallback = "deny", unauthenticated_fallback = "deny", fallback_effects = E.log_warn, }, } | C.FileRbacConfig | RoleNamesConsistent