Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6a2e0a5f |
@@ -134,50 +134,8 @@ Rules:
|
|||||||
appears in both, it is forbidden.
|
appears in both, it is forbidden.
|
||||||
- An operation not present in `allowed_operations` is treated as **not
|
- An operation not present in `allowed_operations` is treated as **not
|
||||||
allowed** (deny by default).
|
allowed** (deny by default).
|
||||||
|
- These categories are descriptive for this issue. Their runtime enforcement is
|
||||||
## Operation-name normalization (#106)
|
out of scope here (see roadmap links).
|
||||||
|
|
||||||
Canonical operation names are namespaced: `{service}.{area}.{verb}` (e.g.
|
|
||||||
`gitea.pr.merge`, `jenkins.build.read`). Legacy unqualified spellings are
|
|
||||||
accepted **only** through the explicit alias table below (the code of record
|
|
||||||
is `GITEA_OPERATION_ALIASES` in `gitea_config.py`; the enforcement matrix is
|
|
||||||
`tests/test_op_normalization.py`).
|
|
||||||
|
|
||||||
| Legacy spelling | Canonical operation |
|
|
||||||
|-------------------|----------------------------|
|
|
||||||
| `read` | `gitea.read` |
|
|
||||||
| `review` | `gitea.pr.review` |
|
|
||||||
| `comment` | `gitea.pr.comment` |
|
|
||||||
| `approve` | `gitea.pr.approve` |
|
|
||||||
| `request_changes` | `gitea.pr.request_changes` |
|
|
||||||
| `merge` | `gitea.pr.merge` |
|
|
||||||
| `pr.create` | `gitea.pr.create` |
|
|
||||||
| `branch.push` | `gitea.branch.push` |
|
|
||||||
| `branch` | `gitea.branch.create` |
|
|
||||||
| `commit` | `gitea.repo.commit` |
|
|
||||||
| `push` | `gitea.branch.push` |
|
|
||||||
| `open_pr` | `gitea.pr.create` |
|
|
||||||
|
|
||||||
For non-Gitea services, a single unqualified word namespaces to the checked
|
|
||||||
service (`read` → `jenkins.read` when checking Jenkins); names already
|
|
||||||
prefixed with that service pass through unchanged.
|
|
||||||
|
|
||||||
Enforcement rules (`gitea_config.check_operation`, run **before** any
|
|
||||||
allowed/forbidden membership check):
|
|
||||||
|
|
||||||
- Unknown operation names fail closed (denied).
|
|
||||||
- Ambiguous names — dotted names that are neither service-prefixed nor in the
|
|
||||||
alias table — fail closed.
|
|
||||||
- Cross-service names are never accepted by the wrong service
|
|
||||||
(`jenkins.read` never matches a Gitea check, and a Gitea alias is never
|
|
||||||
applied to another service).
|
|
||||||
- `forbidden_operations` overrides `allowed_operations` after both sides are
|
|
||||||
normalized, so a legacy spelling can never bypass a canonical forbidden
|
|
||||||
entry (or vice versa).
|
|
||||||
- An allowed entry that cannot be normalized grants nothing; a forbidden
|
|
||||||
entry that cannot be normalized denies the request. Normalization can
|
|
||||||
therefore never silently widen permissions.
|
|
||||||
- An empty or missing `allowed_operations` list denies everything.
|
|
||||||
|
|
||||||
## Identity and fail-closed rules
|
## Identity and fail-closed rules
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,9 @@ with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
|
|||||||
|
|
||||||
## Releases and version tags
|
## Releases and version tags
|
||||||
|
|
||||||
|
All release tagging, version bumps, and validation must comply with the [Release / Version Process SOP](release-version-sop.md).
|
||||||
|
|
||||||
|
|
||||||
Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while
|
Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while
|
||||||
unstable. Pick the bump by the largest change since the last tag:
|
unstable. Pick the bump by the largest change since the last tag:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Release / Version Process SOP
|
||||||
|
|
||||||
|
Operator standard operating procedure for cutting a versioned release of
|
||||||
|
Gitea-Tools: version bump, checks, merge, tag, and cleanup.
|
||||||
|
|
||||||
|
> **Scope.** This is the **human/operator** SOP. It is deliberately distinct
|
||||||
|
> from [`release-workflows.md`](release-workflows.md), which describes the
|
||||||
|
> **future `release-mcp` orchestrator** boundary (a coordination concept), not
|
||||||
|
> the day-to-day tagging process. When they disagree, this document governs how
|
||||||
|
> a release is actually cut today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Branch flow
|
||||||
|
|
||||||
|
The repo is **`master`-based**. Releases are cut from `master`; there is no
|
||||||
|
separate `dev`/`release` branch unless and until that is explicitly introduced
|
||||||
|
and this SOP is updated to match. All work lands on `master` via reviewed PRs
|
||||||
|
from short-lived, issue-linked branches (e.g. `docs/issue-68-...`).
|
||||||
|
|
||||||
|
## 2. Where "the version" lives
|
||||||
|
|
||||||
|
There is **no `VERSION` file and no `CHANGELOG` file** in the repo today. The
|
||||||
|
released version is expressed **only as an annotated git tag** of the form
|
||||||
|
`vMAJOR.MINOR.PATCH` (existing tags: `v1.0.0`, `v1.0.1`). Release notes are
|
||||||
|
carried as the **annotated tag's message** (via `--notes-file`), not a tracked
|
||||||
|
changelog.
|
||||||
|
|
||||||
|
> Do **not** confuse this with `SUPPORTED_VERSION` in `gitea_config.py` — that is
|
||||||
|
> the **config-schema** version, unrelated to the application release version.
|
||||||
|
|
||||||
|
If a `VERSION`/`CHANGELOG` file is added later, update this SOP to list it under
|
||||||
|
"files to update".
|
||||||
|
|
||||||
|
## 3. Deciding the version bump (SemVer)
|
||||||
|
|
||||||
|
Pick the bump against the last tag using semantic-versioning intent:
|
||||||
|
|
||||||
|
* **PATCH** (`v1.0.1 → v1.0.2`): bug fixes, docs, tests, internal cleanups — no
|
||||||
|
change to tool names, parameters, return payloads, or behavior.
|
||||||
|
* **MINOR** (`v1.0.1 → v1.1.0`): backward-compatible additions — new MCP tool,
|
||||||
|
new optional parameter, new script, additive behavior.
|
||||||
|
* **MAJOR** (`v1.1.0 → v2.0.0`): backward-**incompatible** changes — renamed or
|
||||||
|
removed tools, changed return-payload shape, changed default behavior, or a
|
||||||
|
tightened safety gate that rejects previously-accepted input.
|
||||||
|
|
||||||
|
When unsure between two levels, choose the higher one.
|
||||||
|
|
||||||
|
## 4. Preparing a version-bump / release PR
|
||||||
|
|
||||||
|
Releases are still gated by the normal issue-first, PR-reviewed flow.
|
||||||
|
|
||||||
|
1. Open (or use) a tracking issue for the release and **claim it** with
|
||||||
|
`status:in-progress` (see §9).
|
||||||
|
2. Create an isolated, issue-linked branch + worktree from latest `master`
|
||||||
|
(e.g. `chore/issue-63-v1.1.0`). Never commit directly to `master`.
|
||||||
|
3. Include in the PR:
|
||||||
|
* Any code/docs changes that belong to the release.
|
||||||
|
* The **release notes** for the annotated tag (draft them in the PR body or a
|
||||||
|
notes file you will pass to `scripts/release-tag --notes-file`).
|
||||||
|
* If a `VERSION`/`CHANGELOG` file exists at that time, its update.
|
||||||
|
4. Open the PR **targeting `master`**.
|
||||||
|
|
||||||
|
The tag is **not** created in the PR. Tagging happens only after merge (§6).
|
||||||
|
|
||||||
|
## 5. Required checks before release
|
||||||
|
|
||||||
|
Run all of these green before merging the release PR and before tagging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m py_compile mcp_server.py
|
||||||
|
python3 -m py_compile manage_labels.py
|
||||||
|
bash -n scripts/clear-provenance
|
||||||
|
./venv/bin/python -m pytest tests/ -q
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus a secret sweep (there is no third-party scanner wired in; do a staged-diff
|
||||||
|
sweep — see [`developer-testing-guidelines.md`](developer-testing-guidelines.md)
|
||||||
|
§7):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --cached | grep -nEi "authorization: (basic|bearer)|password[:=]|token=[A-Za-z0-9]" || echo "clean"
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/release-tag` **also** runs the test suite itself before tagging (unless
|
||||||
|
`--skip-tests` is passed), so tests are enforced twice by default.
|
||||||
|
|
||||||
|
## 6. Running `scripts/release-tag`
|
||||||
|
|
||||||
|
Tag **only after** the release PR is merged to `master`. `scripts/release-tag`
|
||||||
|
enforces the tagging policy and is **safe by default** (creates nothing on a
|
||||||
|
dry-run; never pushes without `--push`).
|
||||||
|
|
||||||
|
Before it tags, it requires **all** of:
|
||||||
|
|
||||||
|
* version matches `vMAJOR.MINOR.PATCH` (SemVer);
|
||||||
|
* `fetch --prune` has run;
|
||||||
|
* you are **on `master`**;
|
||||||
|
* the worktree is **clean** (no uncommitted changes);
|
||||||
|
* local `master` **equals** `<remote>/master`;
|
||||||
|
* `HEAD` is that same commit (the commit is present on remote master);
|
||||||
|
* the tag does **not** already exist locally or on the remote;
|
||||||
|
* the test suite passes (unless `--skip-tests`, which warns).
|
||||||
|
|
||||||
|
Typical sequence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Dry-run to confirm the plan (changes nothing)
|
||||||
|
scripts/release-tag --dry-run v1.1.0
|
||||||
|
|
||||||
|
# 2. Create the annotated tag locally, with release notes
|
||||||
|
scripts/release-tag v1.1.0 --notes-file /path/to/release-notes.md
|
||||||
|
|
||||||
|
# 3. Push the tag only when ready
|
||||||
|
scripts/release-tag v1.1.0 --notes-file /path/to/release-notes.md --push
|
||||||
|
```
|
||||||
|
|
||||||
|
Env injection points (mainly for CI/tests):
|
||||||
|
`RELEASE_TAG_REMOTE` (default `prgs`), `RELEASE_TAG_TEST_CMD`
|
||||||
|
(default `./venv/bin/python -m pytest tests/ -q`).
|
||||||
|
|
||||||
|
## 7. Who may merge / tag
|
||||||
|
|
||||||
|
* The release PR must be **merged by someone other than its author** — the
|
||||||
|
author-cannot-merge safety gate applies to releases exactly as to any other PR.
|
||||||
|
* Merge uses the gated `gitea_merge_pr` workflow; CLI/legacy merge is disabled.
|
||||||
|
* Whoever tags must operate on clean master synced to the remote (enforced by
|
||||||
|
`scripts/release-tag`). Tagging is an operator action performed after merge.
|
||||||
|
|
||||||
|
## 8. Self-review / self-merge restrictions
|
||||||
|
|
||||||
|
Release PRs are **not** exempt from the safety model:
|
||||||
|
|
||||||
|
* No self-review — the author may not approve their own release PR.
|
||||||
|
* No self-merge — a different eligible identity merges.
|
||||||
|
* These gates are enforced by the MCP tooling and must not be bypassed.
|
||||||
|
|
||||||
|
## 9. Handling `status:in-progress` during release work
|
||||||
|
|
||||||
|
* **Claim** the release tracking issue with `status:in-progress` before starting.
|
||||||
|
* Keep it claimed while the release PR is open and under review.
|
||||||
|
* On merge/close, the tracker-hygiene automation releases `status:in-progress`
|
||||||
|
for issues the PR closes; if it remains after the release lands, release it
|
||||||
|
explicitly. Do not leave a shipped release issue marked in-progress.
|
||||||
|
|
||||||
|
## 10. Branch / worktree cleanup after merge
|
||||||
|
|
||||||
|
After the release PR merges and the tag is pushed:
|
||||||
|
|
||||||
|
* Delete the remote release branch (if repo policy allows).
|
||||||
|
* Remove the local worktree and delete the local branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree remove branches/<release-worktree>
|
||||||
|
git branch -d <release-branch>
|
||||||
|
git worktree prune
|
||||||
|
```
|
||||||
|
* Confirm the root repo is clean and on `master` synced to the remote.
|
||||||
|
|
||||||
|
## 11. What NOT to do
|
||||||
|
|
||||||
|
* **No direct commits to `master`.** All changes land via reviewed PRs.
|
||||||
|
* **No force-push** (to `master` or to tags).
|
||||||
|
* **No self-merge** of a release PR.
|
||||||
|
* **No tagging before merge** — tag only commits already on remote `master`.
|
||||||
|
* **No release from a dirty worktree** — `scripts/release-tag` refuses, and so
|
||||||
|
should you.
|
||||||
|
* **No `--skip-tests`** for a real release unless there is an explicit,
|
||||||
|
documented reason.
|
||||||
|
* **No re-tagging / moving an existing tag** — pick the next version instead.
|
||||||
|
|
||||||
|
## 12. Post-Merge Verification & Audit Lessons (v1.1.0)
|
||||||
|
|
||||||
|
During the v1.1.0 release audit, we identified a critical reconciliation issue (captured in historical PRs/issues #68 and #82):
|
||||||
|
* **The "Closed" State Trap:** Gitea PRs marked as `closed` are not guaranteed to be `merged` (they can be closed without merging, leading to silent omissions of code/documentation changes).
|
||||||
|
* **Mandatory Post-Merge File/Commit Presence Probe:** Reviewers/mergers must perform explicit post-merge validation. Do not assume a merge succeeded.
|
||||||
|
- Check that the merged branch head is an ancestor of the target branch (`master`):
|
||||||
|
```bash
|
||||||
|
git fetch <remote> --prune
|
||||||
|
git merge-base --is-ancestor <pr-head-sha> <remote>/master
|
||||||
|
```
|
||||||
|
- Probe file presence for expected modifications/additions:
|
||||||
|
```bash
|
||||||
|
git log --oneline -- <expected-file>
|
||||||
|
# and confirm file presence:
|
||||||
|
ls -la docs/release-version-sop.md
|
||||||
|
```
|
||||||
|
* **Verify in Handoff:** Final report blocks must explicitly document the verification method and probe results.
|
||||||
+14
-72
@@ -70,13 +70,11 @@ _TBD_RE = re.compile(r"(?i)^tbd(-|$)")
|
|||||||
# Keys that would mean an inline secret wherever they appear.
|
# Keys that would mean an inline secret wherever they appear.
|
||||||
_INLINE_SECRET_KEYS = ("token", "password", "secret")
|
_INLINE_SECRET_KEYS = ("token", "password", "secret")
|
||||||
|
|
||||||
# ── Operation-name normalization table (#106; minimal subset landed in #103) ───
|
# ── Minimal operation normalization (#103) ─────────────────────────────────────
|
||||||
# Canonical operations are namespaced ({service}.{area}.{verb}). Legacy
|
# Only what the #103 invariants need. The full normalization table, deprecation
|
||||||
# unqualified spellings are accepted ONLY through this explicit table — never
|
# handling, and enforcement test matrix belong to issue #106 — do not grow this
|
||||||
# by guessing. The same table is the documentation of record (see
|
# beyond invariant safety here.
|
||||||
# docs/gitea-execution-profiles.md) and is exercised by
|
_MINIMAL_GITEA_OP_MAP = {
|
||||||
# tests/test_op_normalization.py.
|
|
||||||
GITEA_OPERATION_ALIASES = {
|
|
||||||
"read": "gitea.read",
|
"read": "gitea.read",
|
||||||
"review": "gitea.pr.review",
|
"review": "gitea.pr.review",
|
||||||
"comment": "gitea.pr.comment",
|
"comment": "gitea.pr.comment",
|
||||||
@@ -96,83 +94,27 @@ _REVIEW_MERGE_OPS = frozenset({"gitea.pr.approve", "gitea.pr.merge"})
|
|||||||
_AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"})
|
_AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"})
|
||||||
|
|
||||||
|
|
||||||
def normalize_operation(op, service="gitea"):
|
def _normalize_op(service, op, addr):
|
||||||
"""Return the canonical namespaced name for *op*, or fail closed (#106).
|
"""Normalize *op* for *service*, or fail closed (#103 minimal subset).
|
||||||
|
|
||||||
- already namespaced for this service (``{service}.*``) → unchanged
|
- already namespaced for this service (``{service}.*``) → unchanged
|
||||||
- known unqualified Gitea ops → mapped via ``GITEA_OPERATION_ALIASES``
|
- known unqualified Gitea ops → mapped via ``_MINIMAL_GITEA_OP_MAP``
|
||||||
- unqualified single-word ops on non-Gitea services → ``{service}.{op}``
|
- unqualified single-word ops on non-Gitea services → ``{service}.{op}``
|
||||||
- anything else — foreign service prefixes, dotted names outside the
|
- anything else (foreign prefixes, unknown unqualified names) → ConfigError
|
||||||
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:
|
if not isinstance(op, str) or not op:
|
||||||
raise ConfigError("operation must be a non-empty string (fail closed)")
|
raise ConfigError(f"identity '{addr}' has an empty or non-string operation")
|
||||||
if op.startswith(service + "."):
|
if op.startswith(service + "."):
|
||||||
return op
|
return op
|
||||||
if service == "gitea" and op in GITEA_OPERATION_ALIASES:
|
if service == "gitea" and op in _MINIMAL_GITEA_OP_MAP:
|
||||||
return GITEA_OPERATION_ALIASES[op]
|
return _MINIMAL_GITEA_OP_MAP[op]
|
||||||
if service != "gitea" and "." not in op:
|
if service != "gitea" and "." not in op:
|
||||||
return f"{service}.{op}"
|
return f"{service}.{op}"
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"operation {op!r} cannot be normalized safely for service "
|
f"identity '{addr}' has operation {op!r} that cannot be normalized "
|
||||||
f"'{service}' (unknown, ambiguous, or cross-service; fail closed)"
|
f"safely for service '{service}' (fail closed; full table is issue #106)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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 canonical config location (one file shared by all LLM launchers).
|
||||||
DEFAULT_CONFIG_PATH = os.path.join(
|
DEFAULT_CONFIG_PATH = os.path.join(
|
||||||
os.path.expanduser("~"), ".config", "gitea-tools", "profiles.json"
|
os.path.expanduser("~"), ".config", "gitea-tools", "profiles.json"
|
||||||
|
|||||||
+6
-16
@@ -521,24 +521,14 @@ def gitea_check_pr_eligibility(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
# Profile capability check (metadata only; not enforcement of the action).
|
# 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"]
|
allowed = profile["allowed_operations"]
|
||||||
forbidden = profile["forbidden_operations"]
|
forbidden = profile["forbidden_operations"]
|
||||||
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
|
if not allowed:
|
||||||
if not op_ok:
|
reasons.append("profile has no configured allowed operations (fail closed)")
|
||||||
if op_reason == "no-allowed-operations":
|
if action in forbidden:
|
||||||
reasons.append(
|
reasons.append(f"profile forbids '{action}'")
|
||||||
"profile has no configured allowed operations (fail closed)")
|
elif action not in allowed:
|
||||||
elif op_reason == "forbidden":
|
reasons.append(f"profile is not allowed to {action}")
|
||||||
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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
"""Operation-name normalization table and enforcement tests — issue #106.
|
|
||||||
|
|
||||||
Covers the required matrix from #106:
|
|
||||||
|
|
||||||
- fully qualified allowed / forbidden operations
|
|
||||||
- legacy unqualified allowed / forbidden operations
|
|
||||||
- unknown operations (fail closed)
|
|
||||||
- ambiguous operations (fail closed)
|
|
||||||
- service mismatch (cross-service names never accepted by the wrong service)
|
|
||||||
- forbidden-overrides-allowed
|
|
||||||
- empty / missing allowed list
|
|
||||||
- duplicate operations after normalization
|
|
||||||
- no silent permission widening
|
|
||||||
- eligibility enforcement normalizes before checking
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import gitea_config # noqa: E402
|
|
||||||
from gitea_config import ( # noqa: E402
|
|
||||||
ConfigError,
|
|
||||||
check_operation,
|
|
||||||
normalize_operation,
|
|
||||||
)
|
|
||||||
from mcp_server import gitea_check_pr_eligibility # noqa: E402
|
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# normalize_operation — canonical table
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
class TestNormalizeOperation(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_fully_qualified_gitea_op_unchanged(self):
|
|
||||||
self.assertEqual(normalize_operation("gitea.pr.merge"), "gitea.pr.merge")
|
|
||||||
|
|
||||||
def test_legacy_aliases_map_to_canonical_names(self):
|
|
||||||
expected = {
|
|
||||||
"merge": "gitea.pr.merge",
|
|
||||||
"approve": "gitea.pr.approve",
|
|
||||||
"request_changes": "gitea.pr.request_changes",
|
|
||||||
"review": "gitea.pr.review",
|
|
||||||
"comment": "gitea.pr.comment",
|
|
||||||
"read": "gitea.read",
|
|
||||||
}
|
|
||||||
for legacy, canonical in expected.items():
|
|
||||||
self.assertEqual(normalize_operation(legacy), canonical)
|
|
||||||
|
|
||||||
def test_contexts_shape_author_verbs(self):
|
|
||||||
self.assertEqual(normalize_operation("branch"), "gitea.branch.create")
|
|
||||||
self.assertEqual(normalize_operation("commit"), "gitea.repo.commit")
|
|
||||||
self.assertEqual(normalize_operation("push"), "gitea.branch.push")
|
|
||||||
self.assertEqual(normalize_operation("open_pr"), "gitea.pr.create")
|
|
||||||
|
|
||||||
def test_unknown_unqualified_op_fails_closed(self):
|
|
||||||
with self.assertRaises(ConfigError):
|
|
||||||
normalize_operation("frobnicate")
|
|
||||||
|
|
||||||
def test_ambiguous_dotted_op_fails_closed(self):
|
|
||||||
# Dotted but neither gitea-prefixed nor an explicit alias: refuse to
|
|
||||||
# guess which namespace was meant.
|
|
||||||
with self.assertRaises(ConfigError):
|
|
||||||
normalize_operation("build.read")
|
|
||||||
|
|
||||||
def test_cross_service_name_rejected_by_wrong_service(self):
|
|
||||||
with self.assertRaises(ConfigError):
|
|
||||||
normalize_operation("jenkins.read", service="gitea")
|
|
||||||
with self.assertRaises(ConfigError):
|
|
||||||
normalize_operation("gitea.read", service="jenkins")
|
|
||||||
|
|
||||||
def test_non_gitea_single_word_namespaced_to_service(self):
|
|
||||||
self.assertEqual(normalize_operation("read", service="jenkins"),
|
|
||||||
"jenkins.read")
|
|
||||||
|
|
||||||
def test_non_gitea_qualified_own_prefix_unchanged(self):
|
|
||||||
self.assertEqual(
|
|
||||||
normalize_operation("jenkins.build.read", service="jenkins"),
|
|
||||||
"jenkins.build.read",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_empty_and_non_string_fail_closed(self):
|
|
||||||
for bad in ("", None, 3, ["merge"]):
|
|
||||||
with self.assertRaises(ConfigError):
|
|
||||||
normalize_operation(bad)
|
|
||||||
|
|
||||||
def test_gitea_alias_not_applied_to_other_services(self):
|
|
||||||
# "merge" on jenkins must not resolve to the *gitea* merge permission.
|
|
||||||
self.assertEqual(normalize_operation("merge", service="jenkins"),
|
|
||||||
"jenkins.merge")
|
|
||||||
|
|
||||||
def test_table_is_documented_and_matches_normalization(self):
|
|
||||||
table = gitea_config.GITEA_OPERATION_ALIASES
|
|
||||||
self.assertIsInstance(table, dict)
|
|
||||||
self.assertTrue(table)
|
|
||||||
for legacy, canonical in table.items():
|
|
||||||
self.assertEqual(normalize_operation(legacy), canonical)
|
|
||||||
self.assertTrue(canonical.startswith("gitea."))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# check_operation — enforcement semantics (normalize BEFORE checking)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
class TestCheckOperation(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_fully_qualified_allowed(self):
|
|
||||||
ok, reason = check_operation("gitea.pr.merge", ["gitea.pr.merge"])
|
|
||||||
self.assertTrue(ok)
|
|
||||||
self.assertEqual(reason, "allowed")
|
|
||||||
|
|
||||||
def test_fully_qualified_forbidden(self):
|
|
||||||
ok, reason = check_operation(
|
|
||||||
"gitea.pr.merge", ["gitea.pr.merge"], ["gitea.pr.merge"])
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "forbidden")
|
|
||||||
|
|
||||||
def test_legacy_unqualified_allowed(self):
|
|
||||||
ok, reason = check_operation("merge", ["gitea.pr.merge"])
|
|
||||||
self.assertTrue(ok)
|
|
||||||
self.assertEqual(reason, "allowed")
|
|
||||||
|
|
||||||
def test_legacy_unqualified_forbidden(self):
|
|
||||||
ok, reason = check_operation("merge", ["gitea.pr.merge"], ["merge"])
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "forbidden")
|
|
||||||
|
|
||||||
def test_unknown_operation_fails_closed(self):
|
|
||||||
ok, reason = check_operation("frobnicate", ["gitea.read"])
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "invalid-operation")
|
|
||||||
|
|
||||||
def test_ambiguous_operation_fails_closed(self):
|
|
||||||
ok, reason = check_operation("build.read", ["gitea.read"])
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "invalid-operation")
|
|
||||||
|
|
||||||
def test_service_mismatch_rejected(self):
|
|
||||||
ok, reason = check_operation("jenkins.read", ["gitea.read"])
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "invalid-operation")
|
|
||||||
|
|
||||||
def test_forbidden_overrides_allowed_across_spellings(self):
|
|
||||||
# Allowed via legacy spelling, forbidden via canonical spelling: the
|
|
||||||
# forbidden entry must win after both normalize to the same op.
|
|
||||||
ok, reason = check_operation("merge", ["merge"], ["gitea.pr.merge"])
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "forbidden")
|
|
||||||
|
|
||||||
def test_empty_allowed_list_denies(self):
|
|
||||||
ok, reason = check_operation("gitea.read", [])
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "no-allowed-operations")
|
|
||||||
|
|
||||||
def test_missing_allowed_list_denies(self):
|
|
||||||
ok, reason = check_operation("gitea.read", None)
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "no-allowed-operations")
|
|
||||||
|
|
||||||
def test_duplicates_after_normalization_are_harmless(self):
|
|
||||||
ok, reason = check_operation(
|
|
||||||
"merge", ["merge", "gitea.pr.merge", "merge"])
|
|
||||||
self.assertTrue(ok)
|
|
||||||
self.assertEqual(reason, "allowed")
|
|
||||||
|
|
||||||
def test_unnormalizable_allowed_entry_grants_nothing(self):
|
|
||||||
# A junk allowed entry must not widen permissions to anything.
|
|
||||||
ok, reason = check_operation("gitea.read", ["frobnicate"])
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "not-allowed")
|
|
||||||
|
|
||||||
def test_unnormalizable_forbidden_entry_fails_closed(self):
|
|
||||||
# If a forbidden entry cannot be understood, deny rather than risk
|
|
||||||
# silently narrowing the forbidden set (which would widen permissions).
|
|
||||||
ok, reason = check_operation(
|
|
||||||
"gitea.read", ["gitea.read"], ["frobnicate"])
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertEqual(reason, "invalid-forbidden-entry")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Eligibility enforcement — normalization happens before checking (#106)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
class TestEligibilityNormalizesOperations(unittest.TestCase):
|
|
||||||
|
|
||||||
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
|
||||||
return {
|
|
||||||
"user": {"login": author},
|
|
||||||
"state": state,
|
|
||||||
"head": {"sha": sha},
|
|
||||||
"mergeable": mergeable,
|
|
||||||
}
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_namespaced_profile_ops_allow_legacy_action(self, _auth, mock_api):
|
|
||||||
# JSON-config profiles carry canonical namespaced ops; the raw action
|
|
||||||
# "merge" must still match them after normalization.
|
|
||||||
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.merge"}
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
|
||||||
r = gitea_check_pr_eligibility(pr_number=9, action="merge",
|
|
||||||
remote="prgs")
|
|
||||||
self.assertTrue(r["eligible"])
|
|
||||||
self.assertNotIn("profile is not allowed to merge", r["reasons"])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_namespaced_forbidden_op_blocks_legacy_action(self, _auth, mock_api):
|
|
||||||
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.merge",
|
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.merge"}
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
|
||||||
r = gitea_check_pr_eligibility(pr_number=9, action="merge",
|
|
||||||
remote="prgs")
|
|
||||||
self.assertFalse(r["eligible"])
|
|
||||||
self.assertIn("profile forbids 'merge'", r["reasons"])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_legacy_env_ops_still_work(self, _auth, mock_api):
|
|
||||||
# v1/env behaviour stays compatible: unqualified env ops keep working.
|
|
||||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
|
||||||
r = gitea_check_pr_eligibility(pr_number=5, action="review",
|
|
||||||
remote="prgs")
|
|
||||||
self.assertTrue(r["eligible"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in New Issue
Block a user