Files
client/tasks/approval-gate.md
2026-03-15 19:46:33 +01:00

7.7 KiB

Approval Gate — Implementation Log

Overview

New policy type POLICY_TYPE_EXTERNAL_APPROVAL that requires human approval before a release can deploy to a target environment.

Rules:

  • Scoped to a single release intent + target environment
  • Release author cannot approve (unless admin → red "Bypass" button)
  • All org members can approve
  • Rejection is a vote, not a permanent block
  • No timer retry — NATS signal on decision triggers re-evaluation

Forage (client) Changes — DONE

Proto

New file: interface/proto/forest/v1/policies.proto

  • POLICY_TYPE_EXTERNAL_APPROVAL = 3 added to PolicyType enum
  • ApprovalConfig { target_environment, required_approvals } message
  • ApprovalState { required_approvals, current_approvals, decisions } message
  • ApprovalDecisionEntry { user_id, username, decision, decided_at, comment } message
  • PolicyEvaluation extended with optional ApprovalState approval_state = 10
  • EvaluatePoliciesRequest extended with optional string release_intent_id = 4
  • Policy, CreatePolicyRequest, UpdatePolicyRequest oneofs extended with ApprovalConfig approval = 12
  • New RPCs: ApproveRelease, RejectRelease, GetApprovalState with request/response messages

New file: scripts/sync-protos.sh

  • Copies all .proto files from forest repo to forage, runs buf generate

Regenerated: crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs and forest.v1.tonic.rs

Domain Model

File: crates/forage-core/src/platform/mod.rs

  • PolicyConfig::Approval { target_environment, required_approvals } variant
  • ApprovalState struct
  • ApprovalDecisionEntry struct
  • PolicyEvaluation.approval_state: Option<ApprovalState> field
  • New ForestPlatform trait methods: evaluate_policies, approve_release, reject_release, get_approval_state

gRPC Client

File: crates/forage-server/src/forest_client.rs

  • convert_policy: handles policy::Config::ApprovalPolicyConfig::Approval
  • policy_config_to_grpc: handles PolicyConfig::Approval → gRPC
  • convert_policy_evaluation: maps policy type 3 → "approval", maps approval_state
  • convert_approval_state: maps gRPC ApprovalState → domain
  • evaluate_policies impl: calls PolicyServiceClient::evaluate_policies with release_intent_id
  • approve_release impl: calls PolicyServiceClient::approve_release
  • reject_release impl: calls PolicyServiceClient::reject_release
  • get_approval_state impl: calls PolicyServiceClient::get_approval_state
  • Fixed PipelineStage::Plan match arm (new variant from forest proto sync)
  • Fixed ReleaseRequest missing prepare_only field (new field from forest proto sync)

Test Support

File: crates/forage-server/src/test_support.rs

  • MockPlatformClient: default impls for evaluate_policies, approve_release, reject_release, get_approval_state

Routes

File: crates/forage-server/src/routes/platform.rs

New routes:

  • POST /orgs/{org}/projects/{project}/releases/{slug}/approveapprove_release_submit
  • POST /orgs/{org}/projects/{project}/releases/{slug}/rejectreject_release_submit

New handler structs:

  • ApprovalForm { csrf_token, release_intent_id, target_environment, comment, force_bypass }
  • CreatePolicyForm extended with required_approvals: Option<i32>

Modified handlers:

  • create_policy_submit: handles policy_type = "approval" with validation
  • policies_page: maps PolicyConfig::Approval to template context
  • edit_policy_page: maps PolicyConfig::Approval to template context
  • artifact_detail: fetches policy evaluations per environment, passes policy_evaluations, release_intent_id, is_release_author, is_admin to template

Templates

File: templates/pages/policies.html.jinja

  • Policy list: "Approval Required" badge with target env + approval count
  • Create form: "Approval Required" option in type dropdown
  • Approval fields: target environment select + required approvals number input
  • JavaScript: toggles visibility of soak/branch/approval field sets

File: templates/pages/artifact_detail.html.jinja

  • New "Policy Requirements" section between Pipeline and Destinations
  • Shows all policy evaluations (soak, branch, approval) with pass/fail icons
  • Approval UI:
    • Approval count badge (current/required)
    • Decision history (username, approved/rejected, comment)
    • Approve button (green) — shown to non-authors
    • Bypass (Admin) button (red) — shown to admin authors with confirmation dialog
    • Reject button (red outline) — shown to all eligible members
    • "You cannot approve your own release" message for non-admin authors

Forest (core) Changes — NEEDS MANUAL APPLICATION

Proto

File: interface/proto/forest/v1/policies.proto — same changes as forage copy above

DB Migration

New file: crates/forest-server/migrations/20260315000001_approval_decisions.sql

CREATE TABLE approval_decisions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    release_intent_id UUID NOT NULL REFERENCES release_intents(id) ON DELETE CASCADE,
    policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE,
    target_environment TEXT NOT NULL,
    user_id UUID NOT NULL,
    username TEXT NOT NULL,
    decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')),
    comment TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- unique per user per intent per env, lookup index for counting

Policy Engine

File: crates/forest-server/src/services/policy.rs

  • PolicyType::Approval, ApprovalConfig struct
  • PolicyConfig::Approval(ApprovalConfig) variant
  • ApprovalStateInfo, ApprovalDecisionInfo structs
  • PolicyEvaluation.approval_state: Option<ApprovalStateInfo>
  • evaluate_for_environment gains release_intent_id: Option<&Uuid> param
  • check_approval: queries approval_decisions, compares count vs required
  • record_approval_decision: upserts into approval_decisions
  • get_intent_actor_id: queries release_intents.actor_id
  • find_approval_policy_for_environment: finds enabled approval policy for target env
  • get_approval_state: returns current approval state for display

Intent Coordinator

File: crates/forest-server/src/intent_coordinator.rs

  • check_approval_policies called after check_soak_time_policies for deploy stages
  • If blocked: logs and continues (no timer retry, NATS-triggered re-eval on decision)

Release Event Store

File: crates/forest-server/src/services/release_event_store.rs

  • check_approval_policies(tx, project_id, release_intent_id, target_environment) -> Option<String>
  • Loads enabled approval policies, counts approved decisions, blocks if insufficient

gRPC Handlers

File: crates/forest-server/src/grpc/policies.rs

  • record_to_grpc: handles PolicyConfig::Approval
  • eval_to_grpc: handles PolicyType::Approval, maps approval_state
  • extract_config / extract_update_config: handles approval config
  • evaluate_policies: passes release_intent_id through
  • approve_release: validates actor != intent author (unless force_bypass), records decision, publishes NATS
  • reject_release: records rejection decision
  • get_approval_state: returns current approval state

Caller Updates

  • src/grpc/release.rs: evaluate_for_environment calls gain None as 4th arg
  • src/scheduler.rs: same

Verification

  • Forage: 169 tests passing, compiles clean (0 errors, 0 warnings)
  • Forest: tool permissions blocked writes — all code is ready, needs to be applied from forest repo context

Next Steps

  1. Apply forest changes (run claude from the forest directory, or grant write access)
  2. Run buf generate in forest to regenerate gRPC interface stubs
  3. Run forest tests
  4. E2E test: create approval policy, trigger release, verify UI shows approval buttons