#!/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"}}, "approver": {"scope": "project", "allow": {"settings:read", "monitoring:read", "approval:grant"}}, "operator": {"scope": "project", "allow": {"monitoring:read", "kill_switch:engage"}}, "viewer": {"scope": "project", "allow": {"settings:read", "monitoring:read"}}, "auditor": {"scope": "org", "allow": {"settings:read", "monitoring:read", "audit:read"}}, } # 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): 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 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("--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 ) 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())