Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.
- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
.specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
- .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
-> packages/casan-harness/... (.specify/logs state kept)
- .claude/launch.json, .gitea/*-runbook.md: path prefixes
- CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
- policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.
Full gate from the new root: PASS=64 FAIL=0 SKIP=3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
6.1 KiB
Python
164 lines
6.1 KiB
Python
#!/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,
|
||
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())
|