feat: operation-name normalization table with fail-closed enforcement (#106)
Promote the #103 minimal alias map to the documented public table GITEA_OPERATION_ALIASES and add the #106 enforcement layer: - normalize_operation(op, service): canonical namespaced names; legacy spellings accepted only via the explicit table; unknown, ambiguous, and cross-service names fail closed. - check_operation(op, allowed, forbidden, service): normalizes BOTH the requested operation and the profile lists before any membership check; forbidden always overrides allowed; unnormalizable allowed entries grant nothing and unnormalizable forbidden entries deny the request, so normalization can never silently widen permissions; empty/missing allowed list denies everything. - gitea_check_pr_eligibility now routes its capability check through check_operation, fixing the mismatch where canonical namespaced profile ops (gitea.pr.merge) never matched the raw action (merge) and namespaced forbidden entries were never enforced. - Document the normalization table and enforcement rules in docs/gitea-execution-profiles.md, replacing the stale 'enforcement out of scope' caveat. - tests/test_op_normalization.py: full #106 matrix (27 tests) — qualified/legacy allowed and forbidden, unknown, ambiguous, service mismatch, forbidden-overrides-allowed, empty/missing allowed, duplicates after normalization, no permission widening, and eligibility integration proving normalization happens before enforcement. Existing v1/env unqualified behaviour stays compatible. Closes #106 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+16
-6
@@ -521,14 +521,24 @@ def gitea_check_pr_eligibility(
|
||||
return result
|
||||
|
||||
# Profile capability check (metadata only; not enforcement of the action).
|
||||
# Both the action and the profile lists are normalized before comparison
|
||||
# (#106), so legacy spellings ("merge") and canonical namespaced ops
|
||||
# ("gitea.pr.merge") always match each other and never cross services.
|
||||
allowed = profile["allowed_operations"]
|
||||
forbidden = profile["forbidden_operations"]
|
||||
if not allowed:
|
||||
reasons.append("profile has no configured allowed operations (fail closed)")
|
||||
if action in forbidden:
|
||||
reasons.append(f"profile forbids '{action}'")
|
||||
elif action not in allowed:
|
||||
reasons.append(f"profile is not allowed to {action}")
|
||||
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
|
||||
if not op_ok:
|
||||
if op_reason == "no-allowed-operations":
|
||||
reasons.append(
|
||||
"profile has no configured allowed operations (fail closed)")
|
||||
elif op_reason == "forbidden":
|
||||
reasons.append(f"profile forbids '{action}'")
|
||||
elif op_reason == "invalid-forbidden-entry":
|
||||
reasons.append(
|
||||
"profile has an unrecognized forbidden operation entry "
|
||||
"(fail closed)")
|
||||
else:
|
||||
reasons.append(f"profile is not allowed to {action}")
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user