#!/usr/bin/env python3 """CASAN RBAC — role-based access control decision engine (Plan-14 core, harness-owned). Reusable governance asset in the core harness (not in any generated app). The Control Plane web app and any harness action call this to authorize a request. Model: role × (resource:action) with scope. deny-by-default + fail-closed: unknown role / action / cross-tenant / sensitive-without-org-admin ⇒ DENY. Separation of Duties (SoD): a proposer cannot approve their own request. Exit codes: 0 = ALLOW, 1 = DENY. Reason printed to stderr. """ import argparse import sys # scope: "org" (all projects) or "project" (must match the acted-on project) PERMISSIONS = { "org-admin": {"scope": "org", "allow": {"*"}}, "project-admin": {"scope": "project", "allow": {"settings:read", "settings:write", "monitoring:read", "audit:read", "chat:select_agent"}}, "approver": {"scope": "project", "allow": {"settings:read", "monitoring:read", "approval:grant", "chat:select_agent"}}, "operator": {"scope": "project", "allow": {"monitoring:read", "kill_switch:engage", "chat:select_agent"}}, "viewer": {"scope": "project", "allow": {"settings:read", "monitoring:read", "chat:select_agent"}}, "auditor": {"scope": "org", "allow": {"settings:read", "monitoring:read", "audit:read", "chat:select_agent"}}, } # Maps an IdP-issued claim (role name / group) to an RBAC role. The IdP (Plan-07 # C4) is the identity authority; RBAC only maps a *verified* claim to a role. # deny-by-default: an unmapped claim yields no role. CLAIM_ROLE_MAP = { "casan-org-admin": "org-admin", "casan-project-admin": "project-admin", "casan-approver": "approver", "casan-operator": "operator", "casan-viewer": "viewer", "casan-auditor": "auditor", } def decide(role, resource, action, role_project, target_project, sensitive, role_tenant="", target_tenant=""): # SEC-23 (MT-01): tenant isolation is enforced at the DATA layer BEFORE any role # grant — even an org-admin of tenant A may not act on tenant B's resources. if (role_tenant or target_tenant) and role_tenant != target_tenant: return False, f"CROSS_TENANT_DENY tenant={role_tenant or 'none'}!={target_tenant or 'none'}" perm = PERMISSIONS.get(role) if perm is None: return False, f"UNKNOWN_ROLE {role}" action_key = f"{resource}:{action}" # Sensitive settings writes are org-admin only, regardless of other grants. if sensitive and not (resource == "settings" and action == "write"): # sensitivity only meaningful for settings:write pass if sensitive and resource == "settings" and action == "write" and role != "org-admin": return False, f"SENSITIVE_REQUIRES_ORG_ADMIN {action_key}" if "*" in perm["allow"]: return True, "ALLOW org-admin" if action_key not in perm["allow"]: return False, f"ACTION_NOT_ALLOWED {role} {action_key}" if perm["scope"] == "project": if not role_project or not target_project: return False, "PROJECT_SCOPE_REQUIRED" if role_project != target_project: return False, f"CROSS_TENANT {role_project}!={target_project}" return True, f"ALLOW {role} {action_key}" def _audit_decision(args, verdict, reason): """Plan-14: write each RBAC decision to an H5-style oversight log (opt-in via CASAN_RBAC_AUDIT_LOG). Append-only; feeds the RAI/Control-Plane oversight view. Off by default so existing flows are unchanged.""" import datetime import json import os path = os.environ.get("CASAN_RBAC_AUDIT_LOG") if not path: return rec = { "timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "harness": "H5-rbac", "role": args.role, "resource": args.resource, "action": f"{args.resource}:{args.action}", "role_tenant": args.role_tenant or None, "target_tenant": args.target_tenant or None, "verdict": verdict, "reason": reason, } try: d = os.path.dirname(path) if d: os.makedirs(d, exist_ok=True) with open(path, "a", encoding="utf-8") as fh: fh.write(json.dumps(rec, ensure_ascii=False) + "\n") except OSError: pass def main() -> int: ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="cmd", required=True) c = sub.add_parser("check") c.add_argument("--role", required=True) c.add_argument("--resource", required=True) c.add_argument("--action", required=True) c.add_argument("--role-project", default="") c.add_argument("--target-project", default="") c.add_argument("--role-tenant", default="") c.add_argument("--target-tenant", default="") c.add_argument("--sensitive", action="store_true") s = sub.add_parser("check-sod") s.add_argument("--proposer", required=True) s.add_argument("--approver", required=True) p = sub.add_parser("list-roles") m = sub.add_parser("map-claim") m.add_argument("--claim", required=True, help="IdP-issued role/group claim") args = ap.parse_args() if args.cmd == "list-roles": for role, perm in PERMISSIONS.items(): print(f"{role} scope={perm['scope']} allow={sorted(perm['allow'])}") return 0 if args.cmd == "map-claim": role = CLAIM_ROLE_MAP.get(args.claim) if role is None: print(f"RBAC_DENY UNKNOWN_CLAIM {args.claim}", file=sys.stderr) return 1 print(role) return 0 if args.cmd == "check-sod": if args.proposer == args.approver: print(f"RBAC_DENY SOD_SELF_APPROVAL actor={args.approver}", file=sys.stderr) return 1 print(f"RBAC_ALLOW SOD_OK proposer={args.proposer} approver={args.approver}") return 0 allowed, reason = decide( args.role, args.resource, args.action, args.role_project, args.target_project, args.sensitive, args.role_tenant, args.target_tenant, ) _audit_decision(args, "ALLOW" if allowed else "DENY", reason) if allowed: print(f"RBAC_ALLOW {reason}") return 0 print(f"RBAC_DENY {reason}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())