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:
+72
-14
@@ -70,11 +70,13 @@ _TBD_RE = re.compile(r"(?i)^tbd(-|$)")
|
||||
# Keys that would mean an inline secret wherever they appear.
|
||||
_INLINE_SECRET_KEYS = ("token", "password", "secret")
|
||||
|
||||
# ── Minimal operation normalization (#103) ─────────────────────────────────────
|
||||
# Only what the #103 invariants need. The full normalization table, deprecation
|
||||
# handling, and enforcement test matrix belong to issue #106 — do not grow this
|
||||
# beyond invariant safety here.
|
||||
_MINIMAL_GITEA_OP_MAP = {
|
||||
# ── Operation-name normalization table (#106; minimal subset landed in #103) ───
|
||||
# Canonical operations are namespaced ({service}.{area}.{verb}). Legacy
|
||||
# unqualified spellings are accepted ONLY through this explicit table — never
|
||||
# by guessing. The same table is the documentation of record (see
|
||||
# docs/gitea-execution-profiles.md) and is exercised by
|
||||
# tests/test_op_normalization.py.
|
||||
GITEA_OPERATION_ALIASES = {
|
||||
"read": "gitea.read",
|
||||
"review": "gitea.pr.review",
|
||||
"comment": "gitea.pr.comment",
|
||||
@@ -94,27 +96,83 @@ _REVIEW_MERGE_OPS = frozenset({"gitea.pr.approve", "gitea.pr.merge"})
|
||||
_AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"})
|
||||
|
||||
|
||||
def _normalize_op(service, op, addr):
|
||||
"""Normalize *op* for *service*, or fail closed (#103 minimal subset).
|
||||
def normalize_operation(op, service="gitea"):
|
||||
"""Return the canonical namespaced name for *op*, or fail closed (#106).
|
||||
|
||||
- already namespaced for this service (``{service}.*``) → unchanged
|
||||
- known unqualified Gitea ops → mapped via ``_MINIMAL_GITEA_OP_MAP``
|
||||
- known unqualified Gitea ops → mapped via ``GITEA_OPERATION_ALIASES``
|
||||
- unqualified single-word ops on non-Gitea services → ``{service}.{op}``
|
||||
- anything else (foreign prefixes, unknown unqualified names) → ConfigError
|
||||
- anything else — foreign service prefixes, dotted names outside the
|
||||
table, unknown unqualified names — is unknown or ambiguous → ConfigError
|
||||
|
||||
Normalization never crosses services (a Gitea alias is never applied to
|
||||
another service) and never widens permissions: an operation that cannot
|
||||
be normalized grants and matches nothing.
|
||||
"""
|
||||
if not isinstance(op, str) or not op:
|
||||
raise ConfigError(f"identity '{addr}' has an empty or non-string operation")
|
||||
raise ConfigError("operation must be a non-empty string (fail closed)")
|
||||
if op.startswith(service + "."):
|
||||
return op
|
||||
if service == "gitea" and op in _MINIMAL_GITEA_OP_MAP:
|
||||
return _MINIMAL_GITEA_OP_MAP[op]
|
||||
if service == "gitea" and op in GITEA_OPERATION_ALIASES:
|
||||
return GITEA_OPERATION_ALIASES[op]
|
||||
if service != "gitea" and "." not in op:
|
||||
return f"{service}.{op}"
|
||||
raise ConfigError(
|
||||
f"identity '{addr}' has operation {op!r} that cannot be normalized "
|
||||
f"safely for service '{service}' (fail closed; full table is issue #106)"
|
||||
f"operation {op!r} cannot be normalized safely for service "
|
||||
f"'{service}' (unknown, ambiguous, or cross-service; fail closed)"
|
||||
)
|
||||
|
||||
|
||||
def check_operation(op, allowed, forbidden=(), service="gitea"):
|
||||
"""Decide whether *op* is permitted. Returns ``(bool, reason)`` (#106).
|
||||
|
||||
Everything is normalized via :func:`normalize_operation` BEFORE any
|
||||
membership check, so legacy and canonical spellings always compare equal.
|
||||
Reasons: ``allowed``, ``invalid-operation``, ``invalid-forbidden-entry``,
|
||||
``forbidden``, ``no-allowed-operations``, ``not-allowed``.
|
||||
|
||||
Fail-closed rules:
|
||||
- an *op* that cannot be normalized is denied (``invalid-operation``)
|
||||
- a forbidden entry that cannot be normalized denies the request
|
||||
(``invalid-forbidden-entry``) — dropping it would silently narrow the
|
||||
forbidden set, i.e. widen permissions
|
||||
- an allowed entry that cannot be normalized is ignored — it grants
|
||||
nothing, so permissions never widen
|
||||
- ``forbidden`` always overrides ``allowed``
|
||||
- an empty or missing allowed list denies everything
|
||||
"""
|
||||
try:
|
||||
op_n = normalize_operation(op, service)
|
||||
except ConfigError:
|
||||
return (False, "invalid-operation")
|
||||
forbidden_n = set()
|
||||
for entry in (forbidden or ()):
|
||||
try:
|
||||
forbidden_n.add(normalize_operation(entry, service))
|
||||
except ConfigError:
|
||||
return (False, "invalid-forbidden-entry")
|
||||
if op_n in forbidden_n:
|
||||
return (False, "forbidden")
|
||||
if not allowed:
|
||||
return (False, "no-allowed-operations")
|
||||
allowed_n = set()
|
||||
for entry in allowed:
|
||||
try:
|
||||
allowed_n.add(normalize_operation(entry, service))
|
||||
except ConfigError:
|
||||
continue
|
||||
if op_n in allowed_n:
|
||||
return (True, "allowed")
|
||||
return (False, "not-allowed")
|
||||
|
||||
|
||||
def _normalize_op(service, op, addr):
|
||||
"""Normalize *op* for identity *addr*, or fail closed with context."""
|
||||
try:
|
||||
return normalize_operation(op, service)
|
||||
except ConfigError as exc:
|
||||
raise ConfigError(f"identity '{addr}': {exc}") from None
|
||||
|
||||
# Default canonical config location (one file shared by all LLM launchers).
|
||||
DEFAULT_CONFIG_PATH = os.path.join(
|
||||
os.path.expanduser("~"), ".config", "gitea-tools", "profiles.json"
|
||||
|
||||
Reference in New Issue
Block a user