diff --git a/README.md b/README.md index f2d1be9..d30df65 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Then initialize the store once: ```bash $ quorumgit init store: /home/you/.quorumgit/quorumgit.db -migrations applied: ['001_core.sql', '002_approval_identities.sql', '003_approver_policy.sql'] +migrations applied: ['001_core.sql', '002_approval_identities.sql', '003_approver_policy.sql', '004_open_ref_namespaces.sql'] contract: ok ``` @@ -171,7 +171,7 @@ Checkpoints in the hub model take an explicit `--commit `. Continuation poi ## Protected operations and approvals -Updates to protected refs, force pushes, ref deletions, pushes that change paths outside the pusher's claimed scopes, and lease takeovers all require an approval. An approval is bound by SHA-256 hash to the **exact operation payload** (canonical JSON) — approving one push authorizes that push at those exact revisions, and nothing else. +Updates to protected refs, force pushes, ref deletions, pushes that change paths outside the pusher's claimed scopes, updates to refs outside `refs/heads/`, and lease takeovers all require an approval. An approval is bound by SHA-256 hash to the **exact operation payload** (canonical JSON) — approving one push authorizes that push at those exact revisions, and nothing else. The flow, driven by the rejection messages themselves: @@ -223,6 +223,16 @@ Every roster and policy change is audited. Without a roster or separation of dut `approve request --push` runs the hook's own derivation against the hub, with your clone's object store attached read-only (the way Git exposes incoming objects to a pre-receive hook). It fetches and writes nothing, reads the old revision from the hub's current ref, resolves `--rev` (default `HEAD`) in `--from`, and produces the same operation the hook will compute — protected update, force push, deletion (`--delete`), or out-of-scope update with its path list — for the agent named by `--pusher` (default: the requester). It applies the same branch reservations as the hook, so a push the hook would refuse outright is refused here too. If the hub's ref moves before you push, the operation no longer matches: derive and request again. +**Refs outside branches are governed too.** Claims only coordinate `refs/heads/*`, so a push that creates or moves anything else — a tag, a note, a custom `refs/…` ref — is a `non_branch_ref_update` and needs an approval bound to that exact update. For annotated tags the operation names the tag object, so derive it with `--rev `: `quorumgit approve request --repo myproject --push refs/tags/v1.0 --from ./my-clone --rev v1.0`. A repository that wants a namespace ungoverned (for example, release tooling that tags freely) opens it explicitly: + +```bash +quorumgit repo namespace open myproject refs/tags/ # tag pushes need no approval +quorumgit repo namespace list myproject +quorumgit repo namespace close myproject refs/tags/ # governed again +``` + +Opening a namespace only lifts the non-branch rule: deleting or force-moving a ref there, or updating one listed with `--protected-ref`, still needs an approval. `refs/heads/` itself cannot be opened. Opening and closing are audited. + Takeovers follow the same pattern: claiming a task someone else holds (`claim --takeover`) prints the takeover operation to approve. Its payload includes the incumbent claim ID, so an unused approval cannot displace a later claim by the same agent. The takeover is atomic — the incumbent is released, the replacement claim created, and the approval consumed in one transaction, or none of it happens. A refused takeover leaves the incumbent untouched and the approval unconsumed. ## Command reference @@ -236,6 +246,7 @@ Takeovers follow the same pattern: claiming a task someone else holds (`claim [--protected-ref ]…` | Register a repository | | `quorumgit repo approver add\|remove ` / `repo approver list ` | Manage a repository's approver roster | | `quorumgit repo policy [--separate-duties \| --no-separate-duties]` | Show or change a repository's approval policy | +| `quorumgit repo namespace open\|close ` / `repo namespace list ` | Let updates under a non-branch ref namespace skip approval, or govern it again | | `quorumgit agent add ` | Register an agent identity | | `quorumgit task add --repo --title [--objective ]` | Create a task | | `quorumgit claim --branch --scope … [--no-worktree] [--takeover] [--override-overlap] [--lease-hours ]` | Claim a task | diff --git a/src/quorumgit/cli.py b/src/quorumgit/cli.py index 18c1cbd..83012dd 100644 --- a/src/quorumgit/cli.py +++ b/src/quorumgit/cli.py @@ -169,6 +169,29 @@ def cmd_repo_policy(args, cfg) -> int: return 0 +def cmd_repo_namespace_open(args, cfg) -> int: + with store.connect(cfg) as conn: + prefix = registry.open_namespace(conn, args.repo, args.prefix) + conn.commit() + print(f"{prefix} is open in {args.repo}: updates there need no approval.") + return 0 + + +def cmd_repo_namespace_close(args, cfg) -> int: + with store.connect(cfg) as conn: + prefix = registry.close_namespace(conn, args.repo, args.prefix) + conn.commit() + print(f"{prefix} is closed in {args.repo}: updates there need an approval.") + return 0 + + +def cmd_repo_namespace_list(args, cfg) -> int: + with store.connect(cfg) as conn: + for prefix in registry.get_repository(conn, args.repo)["open_namespaces"]: + print(prefix) + return 0 + + def cmd_agent_add(args, cfg) -> int: with store.connect(cfg) as conn: agent_id = registry.add_agent(conn, args.name) @@ -533,6 +556,20 @@ def add(name, fn, configure=None, parent=None): ), parent=approver) add("list", cmd_repo_approver_list, lambda sp: sp.add_argument("repo"), parent=approver) + namespace = repo.add_parser( + "namespace", + help="non-branch ref namespaces (refs/tags/, …) that skip approval", + ).add_subparsers(dest="action", required=True) + add("open", cmd_repo_namespace_open, lambda sp: ( + sp.add_argument("repo"), + sp.add_argument("prefix", help="e.g. refs/tags/"), + ), parent=namespace) + add("close", cmd_repo_namespace_close, lambda sp: ( + sp.add_argument("repo"), + sp.add_argument("prefix"), + ), parent=namespace) + add("list", cmd_repo_namespace_list, + lambda sp: sp.add_argument("repo"), parent=namespace) add("policy", cmd_repo_policy, lambda sp: ( sp.add_argument("repo"), sp.add_argument("--separate-duties", dest="separate_duties", diff --git a/src/quorumgit/gate.py b/src/quorumgit/gate.py index 32635a9..c7b4599 100644 --- a/src/quorumgit/gate.py +++ b/src/quorumgit/gate.py @@ -562,13 +562,18 @@ def governed_operation( ) protected = refname in repo["protected_refs"] + # Only branches are coordinated by claims; tags, notes, and custom refs + # are governed by approval unless the repository opened their namespace. + outside_branches = not refname.startswith("refs/heads/") and not any( + refname.startswith(prefix) for prefix in repo["open_namespaces"] + ) forced = ( not deletion and not _is_zero(oldrev) and not _is_fast_forward(git_dir, oldrev, newrev, env) ) - if not (protected or deletion or forced or out_of_scope): + if not (protected or deletion or forced or out_of_scope or outside_branches): return None if protected: op_type = "protected_ref_update" @@ -576,6 +581,8 @@ def governed_operation( op_type = "ref_delete" elif forced: op_type = "force_update" + elif outside_branches: + op_type = "non_branch_ref_update" else: op_type = "out_of_scope_update" operation: dict[str, Any] = { @@ -624,9 +631,12 @@ def derive_push_operation( repo = get_repository(conn, repository) get_agent(conn, pusher) hub_git_dir = str(assert_repository_identity_unique(conn, repo)) + # Branches point at commits. Other refs (an annotated tag, say) are pushed + # as the object itself, which is what the hook sees, so do not peel them. + peel = "^{commit}" if refname.startswith("refs/heads/") else "" current = subprocess.run( ["git", "--git-dir", hub_git_dir, "rev-parse", "--verify", "--quiet", - f"{refname}^{{commit}}"], + f"{refname}{peel}"], capture_output=True, text=True, check=False, @@ -644,8 +654,8 @@ def derive_push_operation( raise GateError("Deriving a push needs --from .") source_path = Path(source).resolve() newrev = _git_out( - ["-C", str(source_path), "rev-parse", "--verify", f"{rev}^{{commit}}"], - f"Cannot resolve {rev!r} to a commit in {source_path}", + ["-C", str(source_path), "rev-parse", "--verify", f"{rev}{peel}"], + f"Cannot resolve {rev!r} in {source_path}", ) try: source_common = git_common_dir(source_path) diff --git a/src/quorumgit/migrations/004_open_ref_namespaces.sql b/src/quorumgit/migrations/004_open_ref_namespaces.sql new file mode 100644 index 0000000..fecd551 --- /dev/null +++ b/src/quorumgit/migrations/004_open_ref_namespaces.sql @@ -0,0 +1,15 @@ +-- Ref namespaces outside refs/heads/ that a repository opts out of approval. +-- By default every non-branch ref update (tags, notes, custom refs) needs an +-- approval bound to the exact update. + +CREATE TABLE open_ref_namespaces ( + id INTEGER PRIMARY KEY, + repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + prefix TEXT NOT NULL CHECK ( + prefix GLOB 'refs/?*/' + AND prefix <> 'refs/heads/' + AND substr(prefix, 1, 11) <> 'refs/heads/' + ), + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE (repository_id, prefix) +); diff --git a/src/quorumgit/registry.py b/src/quorumgit/registry.py index f0ae783..20d7382 100644 --- a/src/quorumgit/registry.py +++ b/src/quorumgit/registry.py @@ -28,6 +28,17 @@ def approvers_for_repository(conn: Connection, repository_id: int) -> list[str]: ] +def open_namespaces_for_repository(conn: Connection, repository_id: int) -> list[str]: + return [ + row[0] + for row in conn.execute( + "SELECT prefix FROM open_ref_namespaces WHERE repository_id = ? " + "ORDER BY prefix", + (repository_id,), + ).fetchall() + ] + + def _protected_refs(conn: Connection, repository_id: int) -> list[str]: return [ row[0] @@ -180,6 +191,7 @@ def get_repository(conn: Connection, name: str) -> dict: "protected_refs": _protected_refs(conn, row[0]), "approvers": approvers_for_repository(conn, row[0]), "separate_duties": bool(row[3]), + "open_namespaces": open_namespaces_for_repository(conn, row[0]), } @@ -290,3 +302,64 @@ def set_separate_duties(conn: Connection, repository: str, enabled: bool) -> Non repo["id"], detail={"repository": repository, "separate_duties": enabled}, ) + + +# ------------------------------------------------------------ ref namespaces + + +def _normalize_namespace(prefix: str) -> str: + prefix = prefix.strip() + if not prefix.endswith("/"): + prefix += "/" + if not prefix.startswith("refs/") or prefix.count("/") < 2 or prefix == "refs/": + raise RegistryError( + f"Ref namespace must look like 'refs//', got {prefix!r}." + ) + if prefix.startswith("refs/heads/"): + raise RegistryError( + "Branches (refs/heads/) are governed by claims, protected refs, and " + "approvals; they cannot be opened as a namespace." + ) + return prefix + + +def open_namespace(conn: Connection, repository: str, prefix: str) -> str: + """Let ref updates under a non-branch namespace skip approval.""" + begin_immediate(conn) + repo = get_repository(conn, repository) + prefix = _normalize_namespace(prefix) + if prefix in repo["open_namespaces"]: + raise RegistryError(f"{prefix} is already open in {repository}.") + conn.execute( + "INSERT INTO open_ref_namespaces (repository_id, prefix) VALUES (?, ?)", + (repo["id"], prefix), + ) + audit.record( + conn, + "repository.namespace_opened", + "repository", + repo["id"], + detail={"repository": repository, "prefix": prefix}, + ) + return prefix + + +def close_namespace(conn: Connection, repository: str, prefix: str) -> str: + """Require approval again for ref updates under a namespace.""" + begin_immediate(conn) + repo = get_repository(conn, repository) + prefix = _normalize_namespace(prefix) + cur = conn.execute( + "DELETE FROM open_ref_namespaces WHERE repository_id = ? AND prefix = ?", + (repo["id"], prefix), + ) + if cur.rowcount != 1: + raise RegistryError(f"{prefix} is not open in {repository}.") + audit.record( + conn, + "repository.namespace_closed", + "repository", + repo["id"], + detail={"repository": repository, "prefix": prefix}, + ) + return prefix diff --git a/tests/test_authorization_binding.py b/tests/test_authorization_binding.py index 6b998cc..849732d 100644 --- a/tests/test_authorization_binding.py +++ b/tests/test_authorization_binding.py @@ -23,7 +23,11 @@ def _agents(conn, *names): conn.execute("INSERT INTO agents (name) VALUES (?) ON CONFLICT DO NOTHING", (name,)) -UPGRADES_AFTER_001 = ["002_approval_identities.sql", "003_approver_policy.sql"] +UPGRADES_AFTER_001 = [ + "002_approval_identities.sql", + "003_approver_policy.sql", + "004_open_ref_namespaces.sql", +] def _apply_001(cfg: Config): diff --git a/tests/test_ref_namespaces.py b/tests/test_ref_namespaces.py new file mode 100644 index 0000000..2d351af --- /dev/null +++ b/tests/test_ref_namespaces.py @@ -0,0 +1,179 @@ +"""Refs outside refs/heads/ need approval unless their namespace is open.""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +from quorumgit import audit, gate, registry +from tests.test_gate import _push, _register_agents, _setup + +GIT_ENV = { + **os.environ, + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@localhost", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@localhost", +} + + +def _git(repo, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, capture_output=True, text=True, env=GIT_ENV, + ).stdout.strip() + + +def _approve(conn, operation) -> None: + _register_agents(conn, "operator") + approval = gate.request_approval(conn, operation, requested_by="operator") + gate.vote(conn, approval["id"], "operator", True) + conn.commit() + + +def test_tag_push_requires_exact_approval(committed_conn, tmp_path, cfg): + conn = committed_conn + repo_name, _hub, clone, a, _b = _setup(conn, tmp_path) + _git(clone, "tag", "v1.0") + + rejected = _push(clone, a, "refs/tags/v1.0", cfg=cfg) + assert rejected.returncode != 0 + assert "non_branch_ref_update" in rejected.stderr + + operation = gate.derive_push_operation( + conn, repo_name, "refs/tags/v1.0", a, source=clone, rev="v1.0" + ) + assert operation is not None + assert operation == { + "type": "non_branch_ref_update", + "repository": repo_name, + "refname": "refs/tags/v1.0", + "oldrev": "0" * 40, + "newrev": _git(clone, "rev-parse", "HEAD"), + } + assert gate.operation_hash(operation) in rejected.stderr + _approve(conn, operation) + assert _push(clone, a, "refs/tags/v1.0", cfg=cfg).returncode == 0 + + +def test_annotated_tag_derivation_uses_the_tag_object(committed_conn, tmp_path, cfg): + conn = committed_conn + repo_name, _hub, clone, a, _b = _setup(conn, tmp_path) + _git(clone, "tag", "-a", "v2.0", "-m", "release 2.0") + tag_object = _git(clone, "rev-parse", "v2.0") + assert tag_object != _git(clone, "rev-parse", "v2.0^{commit}") + + operation = gate.derive_push_operation( + conn, repo_name, "refs/tags/v2.0", a, source=clone, rev="v2.0" + ) + assert operation is not None + assert operation["newrev"] == tag_object + rejected = _push(clone, a, "refs/tags/v2.0", cfg=cfg) + assert gate.operation_hash(operation) in rejected.stderr + _approve(conn, operation) + assert _push(clone, a, "refs/tags/v2.0", cfg=cfg).returncode == 0 + + +def test_custom_namespace_is_governed(committed_conn, tmp_path, cfg): + _repo_name, _hub, clone, a, _b = _setup(committed_conn, tmp_path) + result = _push(clone, a, "HEAD:refs/custom/marker", cfg=cfg) + assert result.returncode != 0 + assert "non_branch_ref_update" in result.stderr + + +def test_open_namespace_skips_approval_until_closed(committed_conn, tmp_path, cfg): + conn = committed_conn + repo_name, _hub, clone, a, _b = _setup(conn, tmp_path) + registry.open_namespace(conn, repo_name, "refs/tags") + conn.commit() + + _git(clone, "tag", "open-1") + assert _push(clone, a, "refs/tags/open-1", cfg=cfg).returncode == 0 + # Other namespaces stay governed. + assert _push(clone, a, "HEAD:refs/custom/x", cfg=cfg).returncode != 0 + + registry.close_namespace(conn, repo_name, "refs/tags/") + conn.commit() + _git(clone, "tag", "closed-1") + assert _push(clone, a, "refs/tags/closed-1", cfg=cfg).returncode != 0 + + +def test_open_namespace_still_governs_deletes_and_protected_refs( + committed_conn, tmp_path, cfg +): + conn = committed_conn + repo_name, _hub, clone, a, _b = _setup(conn, tmp_path) + registry.open_namespace(conn, repo_name, "refs/tags/") + conn.execute( + "INSERT INTO protected_refs (repository_id, refname) VALUES (?, ?)", + (registry.get_repository(conn, repo_name)["id"], "refs/tags/stable"), + ) + conn.commit() + + _git(clone, "tag", "temp") + assert _push(clone, a, "refs/tags/temp", cfg=cfg).returncode == 0 + delete = _push(clone, a, ":refs/tags/temp", cfg=cfg) + assert delete.returncode != 0 + assert "ref_delete" in delete.stderr + + _git(clone, "tag", "stable") + protected = _push(clone, a, "refs/tags/stable", cfg=cfg) + assert protected.returncode != 0 + assert "protected_ref_update" in protected.stderr + + +@pytest.mark.parametrize( + ("prefix", "message"), + [ + ("refs/heads/", "cannot be opened"), + ("refs/heads/release/", "cannot be opened"), + ("tags/", "must look like"), + ("refs/", "must look like"), + ], +) +def test_namespace_validation(conn, git_repo, prefix, message): + name = "ns-validate" + registry.add_repository(conn, name, git_repo) + with pytest.raises(registry.RegistryError, match=message): + registry.open_namespace(conn, name, prefix) + + +def test_namespace_lifecycle_is_audited(conn, git_repo): + registry.add_repository(conn, "ns-audit", git_repo) + assert registry.open_namespace(conn, "ns-audit", "refs/notes") == "refs/notes/" + with pytest.raises(registry.RegistryError, match="already open"): + registry.open_namespace(conn, "ns-audit", "refs/notes/") + registry.close_namespace(conn, "ns-audit", "refs/notes/") + with pytest.raises(registry.RegistryError, match="not open"): + registry.close_namespace(conn, "ns-audit", "refs/notes/") + repo_id = registry.get_repository(conn, "ns-audit")["id"] + kinds = [ + e["event_type"] + for e in audit.events(conn, entity="repository", entity_id=repo_id) + ] + assert "repository.namespace_opened" in kinds + assert "repository.namespace_closed" in kinds + + +def test_cli_namespace_commands(committed_conn, git_repo, cfg): + registry.add_repository(committed_conn, "ns-cli", git_repo) + committed_conn.commit() + + def cli(*args): + env = {**os.environ, "QUORUMGIT_DATA_DIR": str(cfg.data_dir)} + return subprocess.run( + [sys.executable, "-m", "quorumgit", *args], + capture_output=True, text=True, env=env, check=False, + ) + + opened = cli("repo", "namespace", "open", "ns-cli", "refs/tags/") + assert opened.returncode == 0, opened.stderr + assert cli("repo", "namespace", "list", "ns-cli").stdout.split() == ["refs/tags/"] + closed = cli("repo", "namespace", "close", "ns-cli", "refs/tags/") + assert closed.returncode == 0, closed.stderr + assert cli("repo", "namespace", "list", "ns-cli").stdout.strip() == "" + refused = cli("repo", "namespace", "open", "ns-cli", "refs/heads/") + assert refused.returncode == 1