283 lines
15 KiB
Text
283 lines
15 KiB
Text
# 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
|