"""`erp-compose check`'s permission half (0.9.2): what each role can EFFECTIVELY run, and where a brick decides by
role name.
EFFECTIVE PERMISSIONS. A role's grants (`pj:*:read`, `*:*:*`) expand against the composition's declared permission
catalog — every permission a use case of a member brick requires, plus the machine permissions — by the runtime's
own rule (auth.has_permission), so what is printed is what the doors will do. A WARNING when a role's WILDCARD
reaches a destructive or approval command the role does not name explicitly: `quote:*:*` was meant as "everything
in quotes" and it also approves them. Naming the permission is the answer; so is a narrower wildcard.
WHERE THE ROLES ARE. There is no single source for a composition's roles today: the deployed truth is the pool's
role catalog in dodil-appid (`dodil appid roles get <pool> --org <org> -o json`), and in a repo it is the scenarios'
roles.yaml. So: `--roles FILE` (the appid JSON `{role: [perm]}` or roles.yaml `{roles: {…}, service_clients: […]}`),
else `roles.yaml` beside compose.yaml, else `tests/scenarios/roles.yaml` under it; none found = said so, not guessed.
ROLE NAMES IN A BRICK. A brick decides by permission; roles are the customer's (a brick ships default roles, a
composition maps its own). Any role-name decision in a brick's source is listed: `caller["roles"]` /
`caller.get("roles")` (a partner's business roles are not a caller's), and the pre-0.9.2 role-name helpers (`is_manager`, `require_manager`, `resolve_owner`,
`SEE_ALL_ROLES`, `owner_scope` without `see_all=`). A line exempts itself with `# role-check: <reason>` (on it or
the line above) — the exception is in the brick, with its reason. A warning; `--strict` makes it a problem.
"""
import pathlib
import re
from typing import Optional
import yaml
from erp_platform import auth
APPROVAL = re.compile(r"(?:^|_)(?:approve|accept|sign|authori[sz]e|release|confirm|decide|override|close|post)")
ROLE_USE = re.compile(r"""\b(?:caller|user|who|me|principal|identity)\s*(?:\[\s*["'](?:app_)?roles["']\s*\]|"""
r"""\.get\(\s*["'](?:app_)?roles["'])|\bauth\._roles\(|\bis_manager\(|\brequire_manager\(|"""
r"""\bresolve_owner\(|\bSEE_ALL_ROLES\b|\bowner_scope\((?![^)]*see_all=)""")
EXEMPT = re.compile(r"#\s*role-check:\s*\S")
def find_roles(compose_path, explicit: Optional[str] = None) -> Optional[pathlib.Path]:
if explicit:
return pathlib.Path(explicit)
root = pathlib.Path(compose_path).resolve().parent
for p in (root / "roles.yaml", root / "tests" / "scenarios" / "roles.yaml"):
if p.is_file():
return p
return None
def load_roles(path) -> tuple:
"""(role → grants, service clients) from a roles.yaml or an appid role-catalog JSON."""
spec = yaml.safe_load(pathlib.Path(path).read_text()) or {} # JSON is YAML
roles = spec.get("roles", spec) if isinstance(spec, dict) else {}
services = set(spec.get("service_clients") or []) if "roles" in spec else set()
return {str(r): list(g or []) for r, g in roles.items() if isinstance(g, list)}, services
def catalog(comp) -> dict:
"""permission → [(brick, use case)] for every concrete permission the composition's use cases require."""
out: dict = {}
for m in comp.members:
for uc in m.brick.use_cases:
if uc.permission and auth.is_concrete(uc.permission):
out.setdefault(uc.permission, []).append((m.name, uc))
for p in auth.machine_permissions():
out.setdefault(p, [])
return out
def report(comp, roles: dict, services: set) -> dict:
cat = catalog(comp)
effective, warnings = {}, []
for role, grants in roles.items():
reached = auth.effective(grants, cat)
effective[role] = {}
for p in reached:
for brick, _ in cat[p] or [("(platform)", None)]:
effective[role].setdefault(brick, []).append(p)
for g in grants:
if auth.parse_grant(g) is None:
warnings.append(f"role {role}: grant {g!r} is malformed (module:entity:action, each [a-z_]+ or *) — "
"it is ignored and grants nothing")
named = {g for g in grants if isinstance(g, str) and "*" not in g}
for p in reached:
if p in named:
continue
via = [g for g in grants if isinstance(g, str) and "*" in g and auth.grant_covers(g, p)]
for brick, uc in cat[p]:
kind = ("destructive" if uc.destructive else
"approval" if APPROVAL.search(uc.name) or APPROVAL.search(p.split(":")[2]) else None)
if kind and uc.kind == "command":
warnings.append(f"role {role}: wildcard {', '.join(via)} reaches the {kind} command {brick}."
f"{uc.name} ({p}) without naming it — name {p} in the role, or narrow the wildcard")
machine = sorted(set(grants) & auth.machine_permissions())
if machine and role not in services:
warnings.append(f"role {role}: holds the machine permission(s) {', '.join(machine)} — a service client's, "
"never a person's (list the role under service_clients if it is one)")
for p, users in cat.items():
for brick, uc in users:
if not uc.tool and uc.kind == "command" and not auth.is_machine(p):
warnings.append(f"{brick}.{uc.name}: a seam-only command behind {p}, which is not a machine "
"permission — `*:*:*` reaches it. Declare it Brick(machine_permissions=…)")
return {"effective": effective, "warnings": warnings}
def role_name_uses(comp) -> list:
"""`brick: file:line: code` for every role-name decision in a member brick's source, minus the exempted."""
out = []
for m in comp.members:
pkg = __import__(m.package)
for base in getattr(pkg, "__path__", []):
for f in sorted(pathlib.Path(base).rglob("*.py")):
if "tests" in f.parts:
continue
lines = f.read_text(errors="replace").splitlines()
for i, line in enumerate(lines):
code = line.split("#", 1)[0]
if not ROLE_USE.search(code):
continue
if EXEMPT.search(line) or (i and EXEMPT.search(lines[i - 1])):
continue
out.append(f"{m.name}: {f.relative_to(pathlib.Path(base).parent)}:{i + 1}: {line.strip()[:100]}")
return out
def print_report(r: dict, source, say) -> None:
say(f" roles from {source} — effective permissions per brick (wildcards expanded):")
for role, by in r["effective"].items():
if not by:
say(f" {role}: nothing")
for brick, perms in sorted(by.items()):
say(f" {role:<14} {brick:<10} {', '.join(sorted(set(perms)))}")