website-htmx-rustelo-code/site/rbac.ncl

284 lines
15 KiB
Text
Raw Normal View History

2026-07-10 03:44:13 +01:00
# 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