diff --git a/README.md b/README.md index 2201808..7d45d18 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Eight concepts, in the order you meet them: | **Worktree** | An isolated `git worktree` created per claim. Agents never share a mutable checkout; Git itself refuses to check one branch out twice. A claim that supersedes an earlier claim on the same task and branch continues its retained checkout instead of creating a second one. | | **Handoff** | A structured continuation record (done / remaining / exact commit / blockers) that transfers work to a successor instead of abandoning it. | | **Approval** | A sign-off by eligible agents, hash-bound to one exact operation (a specific push, takeover, or deletion), consumed on use. | -| **Approval policy** | Owned by each repository: how many votes an approval needs, which roles may cast them, and whether the requester may vote. The agent asking for permission never chooses its own quorum. | +| **Approval policy** | Owned by each repository: how many votes an approval needs, which roles may cast them, and whether the requester may vote — as a default, optionally overridden per operation type (a force push can need more votes than a takeover). The agent asking for permission never chooses its own quorum. | | **Content governance** | In the hub model, what a push *actually changes* is derived from the Git objects it carries and checked against the pushing claim's scopes and the repository's protected paths. | Everything an agent does — claim, renew, checkpoint, hand off, release — writes an audit event in the same database transaction. The audit table is append-only, enforced by a trigger. @@ -60,7 +60,7 @@ Then initialize the store once: ```bash $ quorumgit init store: /home/you/.quorumgit/quorumgit.db -migrations applied: ['001_core.sql', '002_approval_identities.sql', '004_approval_authority.sql', '005_content_governance.sql'] +migrations applied: ['001_core.sql', '002_approval_identities.sql', '004_approval_authority.sql', '005_content_governance.sql', '006_ref_updates.sql', '007_ref_update_scope_paths.sql', '008_operation_policies.sql'] contract: ok ``` @@ -260,13 +260,24 @@ Rules that hold no matter what: - **Votes bind to one approval instance.** A delayed vote for an older denied or consumed instance cannot decide a newer request with the same operation hash. Requesters, voters, consumers, and pushers must name registered agents. - **A consumed approval is spent, not blacklisted.** Consumption moves the approval to a terminal `consumed` state (with `consumed_at`) rather than reusing `denied`, and only one *live* (`pending` or `approved`) approval may exist per operation hash. The same operation can therefore be requested and approved again later as a new approval instance — which matters for takeovers, whose payload is stable and legitimately repeatable. What is never possible is one approval authorizing twice. - **Nobody authorizes themselves.** Only agents whose role the repository's policy names may vote, in either direction. The requester may not vote unless the policy says so, the agent a takeover would benefit may not vote on it, and an agent that voted to approve an operation can never be the one that carries it out — pushes and takeovers by an approver are rejected, and a database trigger refuses the consumption even for writers that bypass the CLI. -- **Authority is checked when it is used, not only when it is granted.** Consumption re-derives eligible votes from current roles and policy, so raising the threshold or demoting an approver invalidates approvals that no longer meet it. +- **Authority is checked when it is used, not only when it is granted.** Consumption re-derives eligible votes from current roles and policy, so raising the threshold or demoting an approver invalidates approvals that no longer meet it. Such an approval is reopened by the next vote on it, so eligible agents can bring it up to the new requirement instead of requesting it again. - **The repository owns the policy.** New repositories require one vote from an `operator`, and the requester may not vote. Change it with `quorumgit repo policy [--threshold ] [--role ]… [--requester-may-vote | --no-requester-may-vote]`. `approve request` no longer accepts `--threshold`. +- **Policy can differ per operation type.** A repository's policy is its default; it may override the threshold, the approving roles, or whether the requester may vote for any of `protected_ref_update`, `force_update`, `ref_delete`, `out_of_scope_push`, `protected_path_update`, and `lease_takeover`. Fields an override leaves unset keep following the default, and `--inherit` removes the override. The effective policy is enforced by the same database triggers, and re-derived at consumption like any other policy change. + + ```bash + quorumgit repo policy myproject --operation force_update --threshold 2 --agent lead + quorumgit repo policy myproject --operation ref_delete --threshold 2 --agent lead + quorumgit repo policy myproject --operation out_of_scope_push --role reviewer --role operator --agent lead + quorumgit repo policy myproject # default plus every override + quorumgit repo policy myproject --operation force_update --inherit --agent lead + ``` ### Roles and administration Registering a `reviewer` or `operator`, changing a role (`quorumgit agent role `), and changing repository policy are themselves authority decisions, so each requires an operator acting through `--agent` or `QUORUMGIT_AGENT`, and each is audited. The single exception is bootstrap: while no operator exists, the first one can be designated by anyone. The last remaining operator cannot be demoted, so a store never falls back into bootstrap mode by accident. +> **Upgrading to per-operation policy.** Migration `008_operation_policies.sql` adds no overrides, so every operation keeps following the repository default until an operator sets one. + > **Upgrading to content governance.** Migration `005_content_governance.sql` restricts every existing repository to `refs/heads/`. If your agents push tags or other refs, allow those namespaces with `quorumgit repo allow-ref` after running `quorumgit init`. Claimed branches now also enforce their scopes at push time, so a claim declared too narrowly will ask for `out_of_scope_push` approvals. > **Upgrading.** Migration `004_approval_authority.sql` makes every existing agent a `worker` and gives every existing repository the default policy. Approvals granted under the old rules (including self-approvals) stop authorizing anything until an eligible operator votes on a fresh request. After running `quorumgit init`, designate an operator with `quorumgit agent role operator`. @@ -286,7 +297,7 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c | `quorumgit repo add [--protected-ref ]… [--protected-path ]…` | Register a repository | | `quorumgit agent add [--role worker\|reviewer\|operator]` | Register an agent identity (non-workers need an operator once one exists) | | `quorumgit agent role ` | Change an agent's role (operator only, after bootstrap) | -| `quorumgit repo policy [--threshold ] [--role ]… [--[no-]requester-may-vote]` | Show or change a repository's approval policy (changes are operator only); also shows protected refs, protected paths, and allowed ref namespaces | +| `quorumgit repo policy [--operation [--inherit]] [--threshold ] [--role ]… [--[no-]requester-may-vote]` | Show or change a repository's default approval policy, or its override for one operation type (changes are operator only); also shows protected refs, protected paths, and allowed ref namespaces | | `quorumgit repo protect-path [--remove]` | Require an approval for any push changing matching paths (operator only) | | `quorumgit repo allow-ref [--remove]` | Allow pushes to a ref namespace such as `refs/tags/` (operator only) | | `quorumgit task add --repo --title [--objective ]` | Create a task | diff --git a/src/quorumgit/cli.py b/src/quorumgit/cli.py index 50d2b94..f00a670 100644 --- a/src/quorumgit/cli.py +++ b/src/quorumgit/cli.py @@ -149,30 +149,55 @@ def cmd_repo_list(args, cfg) -> int: return 0 +def _describe_policy(policy: dict) -> str: + return ( + f"threshold {policy['threshold']}; roles " + f"{', '.join(policy['roles']) or 'none'}; requester may vote: " + f"{'yes' if policy['requester_may_vote'] else 'no'}" + ) + + def cmd_repo_policy(args, cfg) -> int: with store.session(cfg) as conn: changing = ( args.threshold is not None or args.role or args.requester_may_vote is not None + or args.inherit ) if changing: - policy = registry.set_approval_policy( + registry.set_approval_policy( conn, args.name, actor=getattr(args, "agent", None) or cfg.agent, threshold=args.threshold, roles=args.role or None, requester_may_vote=args.requester_may_vote, + operation_type=args.operation, + inherit=args.inherit, ) conn.commit() - else: - repo = registry.get_repository(conn, args.name) - policy = registry.approval_policy(conn, repo["id"]) + repo = registry.get_repository(conn, args.name) + default = registry.approval_policy(conn, repo["id"]) + overridden = registry.operation_policy_overrides(conn, repo["id"]) + effective = { + operation_type: registry.approval_policy(conn, repo["id"], operation_type) + for operation_type in registry.OPERATION_TYPES + } print(f"repository: {args.name}") - print(f"approval threshold: {policy['threshold']}") - print(f"approving roles: {', '.join(policy['roles'])}") - print(f"requester may vote: {'yes' if policy['requester_may_vote'] else 'no'}") + if args.operation is not None: + state = "override" if args.operation in overridden else "inherits default" + print(f"{args.operation} ({state}): {_describe_policy(effective[args.operation])}") + return 0 + print(f"approval threshold: {default['threshold']}") + print(f"approving roles: {', '.join(default['roles'])}") + print(f"requester may vote: {'yes' if default['requester_may_vote'] else 'no'}") + if overridden: + print("per-operation overrides:") + for operation_type in overridden: + print(f" {operation_type}: {_describe_policy(effective[operation_type])}") + else: + print("per-operation overrides: none") with store.session(cfg) as conn: repo = registry.get_repository(conn, args.name) print(f"protected refs: {', '.join(repo['protected_refs']) or '-'}") @@ -698,6 +723,12 @@ def add(name, fn, configure=None, parent=None): "the current set)"), sp.add_argument("--requester-may-vote", dest="requester_may_vote", action=argparse.BooleanOptionalAction, default=None), + sp.add_argument("--operation", choices=registry.OPERATION_TYPES, + help="show or change the override for one operation " + "type instead of the repository default"), + sp.add_argument("--inherit", action="store_true", + help="with --operation: remove the override so the " + "type inherits the default again"), ), parent=repo) add("protect-path", cmd_repo_protect_path, lambda sp: ( sp.add_argument("name"), diff --git a/src/quorumgit/gate.py b/src/quorumgit/gate.py index 0722984..8a99846 100644 --- a/src/quorumgit/gate.py +++ b/src/quorumgit/gate.py @@ -130,7 +130,9 @@ def _policy_for(conn: Connection, approval: dict) -> dict: f"Approval {approval['id']} predates repository approval policy " "and names no registered repository; request it again." ) - return approval_policy(conn, approval["repository_id"]) + return approval_policy( + conn, approval["repository_id"], approval["operation"].get("type") + ) def _vote_refusal(approval: dict, policy: dict, voter: dict) -> str | None: @@ -202,7 +204,8 @@ def _authorization_refusal( if eligible < policy["threshold"]: return ( f"Approval {approval['id']} has {eligible} eligible approval(s); the " - f"repository policy now requires {policy['threshold']}." + f"repository policy now requires {policy['threshold']}. Eligible " + f"agents can vote on approval {approval['id']} to reach it." ) return None @@ -240,7 +243,7 @@ def request_approval( if existing is not None: return _approval_dict(existing) - threshold = approval_policy(conn, repo["id"])["threshold"] + threshold = approval_policy(conn, repo["id"], operation.get("type"))["threshold"] row = conn.execute( """ INSERT INTO approvals ( @@ -298,16 +301,45 @@ def vote(conn: Connection, approval_id: int, voter: str, approve: bool) -> dict: approval state. Denial has precedence and terminal states remain final. Only agents the repository's policy recognizes may vote, in either direction; the requester may not vote unless the policy allows it. + + An approved instance that no longer meets the effective policy (its + threshold was raised, or approvers lost their eligibility) is reopened + for voting rather than left stuck: request_approval() keeps returning + that same live instance, so it must be able to collect the votes the + stricter policy now needs. """ begin_immediate(conn) voter_row = get_agent(conn, voter) approval = get_approval_by_id(conn, approval_id) - if approval["status"] != "pending": + if approval["status"] not in ("pending", "approved"): raise GateError(f"Approval {approval_id} is already {approval['status']}.") policy = _policy_for(conn, approval) + stale = ( + approval["status"] == "approved" + and _eligible_approvals(conn, approval, policy) < policy["threshold"] + ) + if approval["status"] == "approved" and not stale: + raise GateError(f"Approval {approval_id} is already approved.") refusal = _vote_refusal(approval, policy, voter_row) if refusal is not None: raise GateError(f"Vote refused: {refusal}") + if stale: + conn.execute( + "UPDATE approvals SET status = 'pending', decided_at = NULL " + "WHERE id = ? AND status = 'approved'", + (approval["id"],), + ) + audit.record( + conn, + "approval.reopened", + "approval", + approval["id"], + agent=voter, + detail={ + "hash": approval["operation_hash"], + "threshold": policy["threshold"], + }, + ) conn.execute( """ INSERT INTO votes (approval_id, voter, vote, voter_agent_id) diff --git a/src/quorumgit/migrations/008_operation_policies.sql b/src/quorumgit/migrations/008_operation_policies.sql new file mode 100644 index 0000000..92bde2f --- /dev/null +++ b/src/quorumgit/migrations/008_operation_policies.sql @@ -0,0 +1,94 @@ +-- Per-operation approval policy. +-- +-- A repository's approval policy is its default. It may override the +-- threshold, the approving roles, and whether the requester may vote for one +-- operation type (a force push needing two votes where a lease takeover needs +-- one, say). A NULL column, or an override with no roles of its own, inherits +-- the repository default, so overrides only ever say what differs. + +CREATE TABLE operation_approval_policies ( + id INTEGER PRIMARY KEY, + repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + operation_type TEXT NOT NULL CHECK (operation_type <> ''), + approval_threshold INTEGER CHECK (approval_threshold IS NULL OR approval_threshold >= 1), + requester_may_vote INTEGER CHECK (requester_may_vote IS NULL OR requester_may_vote IN (0, 1)), + UNIQUE (repository_id, operation_type) +); + +-- quorumgit-statement +CREATE TABLE operation_approval_roles ( + id INTEGER PRIMARY KEY, + policy_id INTEGER NOT NULL + REFERENCES operation_approval_policies(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('worker', 'reviewer', 'operator')), + UNIQUE (policy_id, role) +); + +-- quorumgit-statement +-- The effective policy for every approval: the override for its operation +-- type where one says something, the repository default otherwise. +CREATE VIEW approval_effective_policy AS +SELECT + ap.id AS approval_id, + COALESCE(op.requester_may_vote, r.requester_may_vote) AS requester_may_vote, + COALESCE(op.approval_threshold, r.approval_threshold) AS approval_threshold, + op.id AS override_id, + EXISTS ( + SELECT 1 FROM operation_approval_roles orr WHERE orr.policy_id = op.id + ) AS override_has_roles, + r.id AS repository_id +FROM approvals ap +JOIN repositories r ON r.id = ap.repository_id +LEFT JOIN operation_approval_policies op + ON op.repository_id = r.id + AND op.operation_type = json_extract(ap.operation, '$.type'); + +-- quorumgit-statement +DROP TRIGGER votes_require_eligible_voter; + +-- quorumgit-statement +CREATE TRIGGER votes_require_eligible_voter +BEFORE INSERT ON votes +WHEN NOT EXISTS ( + SELECT 1 + FROM approvals ap + JOIN approval_effective_policy p ON p.approval_id = ap.id + JOIN agents v ON v.id = NEW.voter_agent_id + WHERE ap.id = NEW.approval_id + AND (p.requester_may_vote = 1 + OR ap.requested_by_agent_id IS NOT NEW.voter_agent_id) + AND CASE WHEN p.override_has_roles + THEN EXISTS (SELECT 1 FROM operation_approval_roles orr + WHERE orr.policy_id = p.override_id AND orr.role = v.role) + ELSE EXISTS (SELECT 1 FROM repository_approval_roles rr + WHERE rr.repository_id = p.repository_id AND rr.role = v.role) + END +) +BEGIN + SELECT RAISE(ABORT, 'approval voter is not eligible under repository policy'); +END; + +-- quorumgit-statement +DROP TRIGGER vote_updates_require_eligible_voter; + +-- quorumgit-statement +CREATE TRIGGER vote_updates_require_eligible_voter +BEFORE UPDATE OF vote, voter, voter_agent_id ON votes +WHEN NOT EXISTS ( + SELECT 1 + FROM approvals ap + JOIN approval_effective_policy p ON p.approval_id = ap.id + JOIN agents v ON v.id = NEW.voter_agent_id + WHERE ap.id = NEW.approval_id + AND (p.requester_may_vote = 1 + OR ap.requested_by_agent_id IS NOT NEW.voter_agent_id) + AND CASE WHEN p.override_has_roles + THEN EXISTS (SELECT 1 FROM operation_approval_roles orr + WHERE orr.policy_id = p.override_id AND orr.role = v.role) + ELSE EXISTS (SELECT 1 FROM repository_approval_roles rr + WHERE rr.repository_id = p.repository_id AND rr.role = v.role) + END +) +BEGIN + SELECT RAISE(ABORT, 'approval voter is not eligible under repository policy'); +END; diff --git a/src/quorumgit/registry.py b/src/quorumgit/registry.py index 72335d1..28dab8a 100644 --- a/src/quorumgit/registry.py +++ b/src/quorumgit/registry.py @@ -264,8 +264,28 @@ def get_repository(conn: Connection, name: str) -> dict: return _repository_dict(conn, row) -def approval_policy(conn: Connection, repository_id: int) -> dict: - """The repository-owned rule for who may authorize its operations.""" +# Operation types QuorumGit itself demands approvals for. Per-operation +# policy overrides may name only these. +OPERATION_TYPES = ( + "protected_ref_update", + "force_update", + "ref_delete", + "out_of_scope_push", + "protected_path_update", + "lease_takeover", +) + + +def _validate_operation_type(operation_type: str) -> str: + if operation_type not in OPERATION_TYPES: + raise RegistryError( + f"Unknown operation type {operation_type!r}; expected one of: " + f"{', '.join(OPERATION_TYPES)}." + ) + return operation_type + + +def _default_policy(conn: Connection, repository_id: int) -> dict: row = conn.execute( "SELECT approval_threshold, requester_may_vote FROM repositories " "WHERE id = ?", @@ -288,6 +308,64 @@ def approval_policy(conn: Connection, repository_id: int) -> dict: } +def operation_policy_overrides(conn: Connection, repository_id: int) -> dict: + """Per-operation overrides as stored: None or [] means "inherit".""" + overrides: dict[str, dict] = {} + for policy_id, operation_type, threshold, requester_may_vote in conn.execute( + "SELECT id, operation_type, approval_threshold, requester_may_vote " + "FROM operation_approval_policies WHERE repository_id = ? " + "ORDER BY operation_type", + (repository_id,), + ).fetchall(): + overrides[operation_type] = { + "threshold": threshold, + "requester_may_vote": ( + None if requester_may_vote is None else bool(requester_may_vote) + ), + "roles": [ + r[0] + for r in conn.execute( + "SELECT role FROM operation_approval_roles " + "WHERE policy_id = ? ORDER BY role", + (policy_id,), + ).fetchall() + ], + } + return overrides + + +def approval_policy( + conn: Connection, repository_id: int, operation_type: str | None = None +) -> dict: + """Who may authorize an operation of this type in this repository. + + The repository's default policy, with whatever the override for the + operation type specifies laid over it. Without a type, the default. + """ + policy = _default_policy(conn, repository_id) + if operation_type is None: + return policy + override = operation_policy_overrides(conn, repository_id).get(operation_type) + if override is None: + return policy + return { + "threshold": override["threshold"] or policy["threshold"], + "requester_may_vote": ( + policy["requester_may_vote"] + if override["requester_may_vote"] is None + else override["requester_may_vote"] + ), + "roles": override["roles"] or policy["roles"], + } + + +def _validated_roles(roles: list[str]) -> list[str]: + wanted = sorted({_validate_role(role) for role in roles}) + if not wanted: + raise RegistryError("At least one approving role is required.") + return wanted + + def set_approval_policy( conn: Connection, repository: str, @@ -296,48 +374,135 @@ def set_approval_policy( threshold: int | None = None, roles: list[str] | None = None, requester_may_vote: bool | None = None, + operation_type: str | None = None, + inherit: bool = False, ) -> dict: + """Change a repository's default policy, or its override for one type. + + With ``operation_type``, the given fields are stored as that type's + override; fields left unset keep inheriting the default. ``inherit`` + removes the override entirely. Returns the resulting effective policy. + """ begin_immediate(conn) repo = get_repository(conn, repository) require_operator(conn, actor, "Changing approval policy") - before = approval_policy(conn, repo["id"]) + if threshold is not None and threshold < 1: + raise RegistryError("Approval threshold must be at least 1.") + wanted = _validated_roles(roles) if roles is not None else None + + if operation_type is None: + if inherit: + raise RegistryError("--inherit applies only to an --operation override.") + before = _default_policy(conn, repo["id"]) + _set_default_policy(conn, repo["id"], threshold, wanted, requester_may_vote) + after = _default_policy(conn, repo["id"]) + detail: dict = {"before": before, "after": after} + else: + _validate_operation_type(operation_type) + overrides = operation_policy_overrides(conn, repo["id"]) + before = overrides.get(operation_type) + _set_operation_override( + conn, repo["id"], operation_type, threshold, wanted, + requester_may_vote, inherit, + ) + after = operation_policy_overrides(conn, repo["id"]).get(operation_type) + detail = {"operation_type": operation_type, "before": before, "after": after} + if detail["after"] != detail["before"]: + audit.record( + conn, + "repository.policy_changed", + "repository", + repo["id"], + agent=actor, + detail=detail, + ) + return approval_policy(conn, repo["id"], operation_type) + + +def _set_default_policy( + conn: Connection, + repository_id: int, + threshold: int | None, + roles: list[str] | None, + requester_may_vote: bool | None, +) -> None: if threshold is not None: - if threshold < 1: - raise RegistryError("Approval threshold must be at least 1.") conn.execute( "UPDATE repositories SET approval_threshold = ? WHERE id = ?", - (threshold, repo["id"]), + (threshold, repository_id), ) if roles is not None: - wanted = sorted({_validate_role(role) for role in roles}) - if not wanted: - raise RegistryError("At least one approving role is required.") conn.execute( "DELETE FROM repository_approval_roles WHERE repository_id = ?", - (repo["id"],), + (repository_id,), ) - for role in wanted: + for role in roles: conn.execute( "INSERT INTO repository_approval_roles (repository_id, role) " "VALUES (?, ?)", - (repo["id"], role), + (repository_id, role), ) if requester_may_vote is not None: conn.execute( "UPDATE repositories SET requester_may_vote = ? WHERE id = ?", - (1 if requester_may_vote else 0, repo["id"]), + (1 if requester_may_vote else 0, repository_id), ) - after = approval_policy(conn, repo["id"]) - if after != before: - audit.record( - conn, - "repository.policy_changed", - "repository", - repo["id"], - agent=actor, - detail={"before": before, "after": after}, + + +def _set_operation_override( + conn: Connection, + repository_id: int, + operation_type: str, + threshold: int | None, + roles: list[str] | None, + requester_may_vote: bool | None, + inherit: bool, +) -> None: + if inherit: + if threshold is not None or roles is not None or requester_may_vote is not None: + raise RegistryError( + "--inherit removes the override; it cannot be combined with " + "--threshold, --role, or --requester-may-vote." + ) + conn.execute( + "DELETE FROM operation_approval_policies " + "WHERE repository_id = ? AND operation_type = ?", + (repository_id, operation_type), + ) + return + row = conn.execute( + """ + INSERT INTO operation_approval_policies (repository_id, operation_type) + VALUES (?, ?) + ON CONFLICT (repository_id, operation_type) DO UPDATE + SET operation_type = excluded.operation_type + RETURNING id + """, + (repository_id, operation_type), + ).fetchone() + assert row is not None + policy_id = row[0] + if threshold is not None: + conn.execute( + "UPDATE operation_approval_policies SET approval_threshold = ? " + "WHERE id = ?", + (threshold, policy_id), ) - return after + if requester_may_vote is not None: + conn.execute( + "UPDATE operation_approval_policies SET requester_may_vote = ? " + "WHERE id = ?", + (1 if requester_may_vote else 0, policy_id), + ) + if roles is not None: + conn.execute( + "DELETE FROM operation_approval_roles WHERE policy_id = ?", (policy_id,) + ) + for role in roles: + conn.execute( + "INSERT INTO operation_approval_roles (policy_id, role) VALUES (?, ?)", + (policy_id, role), + ) def _validate_path_glob(glob: str) -> str: diff --git a/src/quorumgit/store.py b/src/quorumgit/store.py index 7266600..6a99aa7 100644 --- a/src/quorumgit/store.py +++ b/src/quorumgit/store.py @@ -55,6 +55,8 @@ "protected_paths", "allowed_ref_namespaces", "ref_updates", + "operation_approval_policies", + "operation_approval_roles", ) # Engine-level governance rules. A store missing any of these would still @@ -75,6 +77,11 @@ ) +# Views the governance triggers read from; a store missing one would pass the +# table and trigger checks yet fail on the next vote. +REQUIRED_VIEWS = ("approval_effective_policy",) + + class StoreError(RuntimeError): pass @@ -497,6 +504,19 @@ def verify_contract(target: Config | Connection | str | Path) -> None: raise ContractViolation( f"Missing required governance triggers: {sorted(missing_triggers)}." ) + + views = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'view'" + ).fetchall() + } + missing_views = set(REQUIRED_VIEWS) - views + if missing_views: + raise ContractViolation( + f"Missing required governance views: {sorted(missing_views)}." + ) + _verify_schema_objects(conn) foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0] diff --git a/tests/test_approval_authority.py b/tests/test_approval_authority.py index cb4692d..d7a74f7 100644 --- a/tests/test_approval_authority.py +++ b/tests/test_approval_authority.py @@ -364,6 +364,7 @@ def test_upgrade_leaves_no_agent_with_authority(tmp_path): "005_content_governance.sql", "006_ref_updates.sql", "007_ref_update_scope_paths.sql", + "008_operation_policies.sql", ] upgraded = store.connect(local) try: diff --git a/tests/test_authorization_binding.py b/tests/test_authorization_binding.py index 3577252..ac5b173 100644 --- a/tests/test_authorization_binding.py +++ b/tests/test_authorization_binding.py @@ -42,6 +42,7 @@ def _apply_001(cfg: Config): "005_content_governance.sql", "006_ref_updates.sql", "007_ref_update_scope_paths.sql", + "008_operation_policies.sql", ] @@ -210,7 +211,8 @@ def test_contract_rejects_001_only_store_until_all_migrations_apply(tmp_path): "Missing required migrations: " "['002_approval_identities.sql', '004_approval_authority.sql', " "'005_content_governance.sql', " - "'006_ref_updates.sql', '007_ref_update_scope_paths.sql']" + "'006_ref_updates.sql', '007_ref_update_scope_paths.sql', " + "'008_operation_policies.sql']" ) with pytest.raises(store.ContractViolation, match=re.escape(message)): store.verify_contract(cfg) diff --git a/tests/test_operation_policies.py b/tests/test_operation_policies.py new file mode 100644 index 0000000..d21da52 --- /dev/null +++ b/tests/test_operation_policies.py @@ -0,0 +1,225 @@ +"""Per-operation approval policy: overrides of the repository default.""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from quorumgit import audit, cli, gate, registry, store +from quorumgit.config import Config +from tests.conftest import OPERATOR, ensure_agent, make_git_repo + + +def _op(repo: str, kind: str, n: int = 1) -> dict: + return { + "type": kind, + "repository": repo, + "refname": "refs/heads/feat/x", + "oldrev": "a" * 40, + "newrev": f"{n:040d}", + } + + +@pytest.fixture() +def people(conn): + ensure_agent(conn, OPERATOR, "operator") + ensure_agent(conn, "op-2", "operator") + ensure_agent(conn, "rev-1", "reviewer") + ensure_agent(conn, "worker-1") + return conn + + +def test_override_threshold_applies_only_to_its_operation(people, approval_repo): + conn = people + registry.set_approval_policy( + conn, approval_repo, actor=OPERATOR, operation_type="force_update", threshold=2 + ) + + forced = gate.request_approval( + conn, _op(approval_repo, "force_update"), requested_by="worker-1" + ) + assert forced["threshold"] == 2 + assert gate.vote(conn, forced["id"], OPERATOR, True)["status"] == "pending" + assert gate.vote(conn, forced["id"], "op-2", True)["status"] == "approved" + + protected = gate.request_approval( + conn, _op(approval_repo, "protected_ref_update"), requested_by="worker-1" + ) + assert protected["threshold"] == 1 + assert gate.vote(conn, protected["id"], OPERATOR, True)["status"] == "approved" + + +def test_override_roles_replace_the_default_roles(people, approval_repo): + conn = people + registry.set_approval_policy( + conn, approval_repo, actor=OPERATOR, + operation_type="out_of_scope_push", roles=["reviewer"], + ) + approval = gate.request_approval( + conn, _op(approval_repo, "out_of_scope_push"), requested_by="worker-1" + ) + with pytest.raises(gate.GateError, match="accepts votes only from: reviewer"): + gate.vote(conn, approval["id"], OPERATOR, True) + # The database enforces the same effective policy for direct writers. + operator_id = registry.get_agent(conn, OPERATOR)["id"] + with pytest.raises(sqlite3.IntegrityError, match="not eligible"): + conn.execute( + "INSERT INTO votes (approval_id, voter, vote, voter_agent_id) " + "VALUES (?, ?, 1, ?)", + (approval["id"], OPERATOR, operator_id), + ) + assert gate.vote(conn, approval["id"], "rev-1", True)["status"] == "approved" + + # Other operation types keep the repository's default roles. + other = gate.request_approval( + conn, _op(approval_repo, "ref_delete"), requested_by="worker-1" + ) + with pytest.raises(gate.GateError, match="accepts votes only from: operator"): + gate.vote(conn, other["id"], "rev-1", True) + + +def test_override_can_let_the_requester_vote(people, approval_repo): + conn = people + registry.set_approval_policy( + conn, approval_repo, actor=OPERATOR, + operation_type="protected_path_update", requester_may_vote=True, + ) + approval = gate.request_approval( + conn, _op(approval_repo, "protected_path_update"), requested_by=OPERATOR + ) + assert gate.vote(conn, approval["id"], OPERATOR, True)["status"] == "approved" + # Still never the one who carries it out. + operation = _op(approval_repo, "protected_path_update") + assert gate.approved_instance(conn, operation, consumer=OPERATOR) is None + assert gate.approved_instance(conn, operation, consumer="worker-1") is not None + + +def test_raising_an_override_invalidates_existing_approvals(people, approval_repo): + conn = people + operation = _op(approval_repo, "force_update") + approval = gate.request_approval(conn, operation, requested_by="worker-1") + gate.vote(conn, approval["id"], OPERATOR, True) + assert gate.approved_instance(conn, operation, consumer="worker-1") is not None + + registry.set_approval_policy( + conn, approval_repo, actor=OPERATOR, operation_type="force_update", threshold=2 + ) + assert gate.approved_instance(conn, operation, consumer="worker-1") is None + with pytest.raises(gate.GateError, match="now requires 2"): + gate.consume_approval(conn, approval["id"], operation, agent="worker-1") + + # The same live instance is reopened and can collect the missing vote. + assert gate.request_approval(conn, operation, requested_by="worker-1")["id"] == ( + approval["id"] + ) + topped_up = gate.vote(conn, approval["id"], "op-2", True) + assert topped_up["status"] == "approved" + assert gate.approved_instance(conn, operation, consumer="worker-1") is not None + events = [e["event_type"] for e in audit.events(conn, entity="approval", + entity_id=approval["id"])] + assert "approval.reopened" in events + + +def test_a_stale_approval_can_also_be_denied(people, approval_repo): + conn = people + operation = _op(approval_repo, "ref_delete") + approval = gate.request_approval(conn, operation, requested_by="worker-1") + gate.vote(conn, approval["id"], OPERATOR, True) + # Still valid: no reopening, so it stays terminal for further votes. + with pytest.raises(gate.GateError, match="already approved"): + gate.vote(conn, approval["id"], "op-2", True) + + registry.set_approval_policy( + conn, approval_repo, actor=OPERATOR, operation_type="ref_delete", threshold=2 + ) + assert gate.vote(conn, approval["id"], "op-2", False)["status"] == "denied" + + +def test_contract_requires_the_effective_policy_view(tmp_path): + local = Config(data_dir=tmp_path / "contract", agent=None) + store.migrate(local) + conn = store.connect(local) + try: + conn.execute("DROP VIEW approval_effective_policy") + conn.commit() + finally: + conn.close() + with pytest.raises(store.ContractViolation, match="approval_effective_policy"): + store.verify_contract(local) + store.destroy(local) + + +def test_unset_override_fields_inherit_and_inherit_removes(people, approval_repo): + conn = people + repo_id = registry.get_repository(conn, approval_repo)["id"] + registry.set_approval_policy( + conn, approval_repo, actor=OPERATOR, operation_type="ref_delete", threshold=3 + ) + # The default changing later still reaches fields the override left unset. + registry.set_approval_policy(conn, approval_repo, actor=OPERATOR, roles=["reviewer"]) + assert registry.approval_policy(conn, repo_id, "ref_delete") == { + "threshold": 3, + "requester_may_vote": False, + "roles": ["reviewer"], + } + + effective = registry.set_approval_policy( + conn, approval_repo, actor=OPERATOR, operation_type="ref_delete", inherit=True + ) + assert effective == registry.approval_policy(conn, repo_id) + assert registry.operation_policy_overrides(conn, repo_id) == {} + changes = [ + e for e in audit.events(conn, entity="repository", entity_id=repo_id) + if e["event_type"] == "repository.policy_changed" + ] + assert len(changes) == 3 + + +def test_override_changes_are_validated_and_operator_only(people, approval_repo): + conn = people + with pytest.raises(registry.RegistryError, match="requires an operator"): + registry.set_approval_policy( + conn, approval_repo, actor="worker-1", + operation_type="force_update", threshold=2, + ) + with pytest.raises(registry.RegistryError, match="Unknown operation type"): + registry.set_approval_policy( + conn, approval_repo, actor=OPERATOR, operation_type="test_op", threshold=2 + ) + with pytest.raises(registry.RegistryError, match="cannot be combined"): + registry.set_approval_policy( + conn, approval_repo, actor=OPERATOR, + operation_type="force_update", threshold=2, inherit=True, + ) + with pytest.raises(registry.RegistryError, match="only to an --operation"): + registry.set_approval_policy(conn, approval_repo, actor=OPERATOR, inherit=True) + + +def test_cli_shows_and_sets_overrides( + committed_conn, tmp_path, cfg, monkeypatch, capsys +): + conn = committed_conn + ensure_agent(conn, OPERATOR, "operator") + name = "policy-cli-" + tmp_path.name[-8:] + registry.add_repository(conn, name, make_git_repo(tmp_path / "repo")) + conn.commit() + monkeypatch.setenv("QUORUMGIT_DATA_DIR", str(cfg.data_dir)) + monkeypatch.setenv("QUORUMGIT_AGENT", OPERATOR) + + assert cli.main([ + "repo", "policy", name, "--operation", "force_update", + "--threshold", "2", "--role", "operator", "--role", "reviewer", + ]) == 0 + out = capsys.readouterr().out + assert "force_update (override): threshold 2; roles operator, reviewer" in out + + assert cli.main(["repo", "policy", name]) == 0 + out = capsys.readouterr().out + assert "per-operation overrides:" in out + assert " force_update: threshold 2" in out + + assert cli.main(["repo", "policy", name, "--operation", "force_update", + "--inherit"]) == 0 + out = capsys.readouterr().out + assert "force_update (inherits default): threshold 1" in out