Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs/src/content/docs/integrations/deploy-approvals/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,14 @@ graph TB

### Git Commit Verification

Every approval is tied to a specific git commit hash:
Every approval is tied to a specific full (40-character) git commit SHA. An API token deploying under an approval must:

- Approval cannot be reused for different code
- Manifest validation ensures deployed images match approved commit
- Prevents deploying arbitrary code even with compromised CI/CD token
- Build the archive it uploaded under that approval (one upload and one build per approval)
- Send no `git-sha`, or one equal to the approved commit (the gateway always uses the approved commit, never the client's)
- Reference only pre-built images, each tagged with the approved commit (`<sha>` or `<sha>-<suffix>`, e.g. `<sha>-amd64`) and matching the app's `service_image_patterns`
- Run only commands listed in `approved_deploy_commands` (an empty list allows none)

`service_image_patterns` is required for approval-bound builds: the commit tag alone does not pin the image repository.

### MFA Step-Up

Expand Down Expand Up @@ -183,7 +186,9 @@ Configure via UI or environment variables:
| `vcs_repo` | Repository in org/repo format |
| `ci_provider` | CI system (circleci) |
| `circleci_approval_job_name` | CircleCI approval job name |
| `circleci_auto_approve_on_approval` | Enable auto-approval |
| `circleci_auto_approve_on_approval` | Enable auto-approval (the gateway first checks the workflow's pipeline builds the approved commit of `vcs_repo`) |
| `service_image_patterns` | **Required.** Map of service name (or `*`) to an anchored regex; `{{GIT_COMMIT}}` is replaced with the approved commit, e.g. `{"*": "docker\\.io/org/app:{{GIT_COMMIT}}-amd64"}` |
| `approved_deploy_commands` | Exact commands a token may run under an approval (e.g. migrations); empty allows none |

<Aside type="tip">
Configure per-app settings via environment variables:
Expand Down
2 changes: 2 additions & 0 deletions internal/gateway/app/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ func (a *App) initJobsClient(sender email.Sender, notifier *slackpkg.Notifier) e
Database: a.Database,
EmailSender: sender,
SlackNotifier: notifier,
CircleCIToken: a.Config.CircleCIToken,
GitHubToken: a.Config.GitHubToken,
}, auditAnchorConfig)
if err != nil {
return fmt.Errorf("failed to initialize jobs client: %w", err)
Expand Down
19 changes: 16 additions & 3 deletions internal/gateway/circleci/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@ type ApprovalMetadata struct {
}

// ApproveJob approves a pending approval job in a CircleCI workflow.
// The workflow's pipeline must be building the approved revision of the expected repository;
// otherwise ErrPipelineMismatch is returned and nothing is approved.
// It handles workflow reruns by finding the latest workflow with the same name in the pipeline.
func (c *Client) ApproveJob(workflowID, pipelineNumber, jobName string) error {
if workflowID == "" {
return fmt.Errorf("workflow_id is required")
func (c *Client) ApproveJob(workflowID, pipelineNumber, jobName string, expect ApprovalExpectation) error {
if err := ValidateWorkflowID(workflowID); err != nil {
return err
}
if pipelineNumber == "" {
return fmt.Errorf("pipeline_number is required")
Expand All @@ -81,6 +83,14 @@ func (c *Client) ApproveJob(workflowID, pipelineNumber, jobName string) error {
return fmt.Errorf("failed to get workflow details: %w", err)
}

pipeline, err := c.getPipeline(originalWorkflow.PipelineID)
if err != nil {
return fmt.Errorf("failed to get pipeline: %w", err)
}
if err := verifyPipeline(pipeline, expect); err != nil {
return err
}

// Find the latest workflow with the same name in the pipeline
// Use the pipeline ID (UUID) from the workflow, not the pipeline number
latestWorkflowID, err := c.findLatestWorkflowByName(originalWorkflow.PipelineID, originalWorkflow.Name)
Expand Down Expand Up @@ -248,6 +258,9 @@ func ValidateMetadata(metadata map[string]interface{}) error {
if !ok || strings.TrimSpace(workflowID) == "" {
return fmt.Errorf("ci_metadata.workflow_id is required")
}
if err := ValidateWorkflowID(workflowID); err != nil {
return fmt.Errorf("ci_metadata.workflow_id: %w", err)
}

pipelineNumber, ok := metadata["pipeline_number"].(string)
if !ok || strings.TrimSpace(pipelineNumber) == "" {
Expand Down
117 changes: 117 additions & 0 deletions internal/gateway/circleci/pipeline_verification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package circleci

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
)

var (
// ErrPipelineMismatch means the workflow's pipeline is not building the approved commit/repository.
ErrPipelineMismatch = errors.New("CircleCI pipeline does not match the deploy approval")
// ErrInvalidWorkflowID means the workflow ID is not a CircleCI UUID.
ErrInvalidWorkflowID = errors.New("invalid CircleCI workflow_id")

uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
)

// ApprovalExpectation is what the pipeline behind a workflow must match before its hold job is approved.
type ApprovalExpectation struct {
// Revision is the full commit SHA the deploy approval was granted for (required).
Revision string
// Repo is the app's "owner/repo" VCS repository. When empty, only the revision is checked.
Repo string
}

// ValidateWorkflowID reports an error unless workflowID is a CircleCI workflow UUID.
func ValidateWorkflowID(workflowID string) error {
if !uuidPattern.MatchString(workflowID) {
return fmt.Errorf("%w: %q", ErrInvalidWorkflowID, workflowID)
}
return nil
}

type pipelineDetails struct {
ID string `json:"id"`
ProjectSlug string `json:"project_slug"`
VCS struct {
Revision string `json:"revision"`
TargetRepositoryURL string `json:"target_repository_url"`
OriginRepositoryURL string `json:"origin_repository_url"`
} `json:"vcs"`
}

func (c *Client) getPipeline(pipelineID string) (*pipelineDetails, error) {
body, err := c.doRequest(http.MethodGet, fmt.Sprintf("%s/pipeline/%s", c.BaseURL, url.PathEscape(pipelineID)))
if err != nil {
return nil, err
}
var details pipelineDetails
if err := json.Unmarshal(body, &details); err != nil {
return nil, fmt.Errorf("failed to parse pipeline: %w", err)
}
return &details, nil
}

// verifyPipeline checks that a pipeline builds the expected revision of the expected repository,
// and is not a pipeline for a fork's code.
func verifyPipeline(pipeline *pipelineDetails, expect ApprovalExpectation) error {
revision := strings.TrimSpace(expect.Revision)
if revision == "" {
return fmt.Errorf("%w: no approved revision", ErrPipelineMismatch)
}
if !strings.EqualFold(strings.TrimSpace(pipeline.VCS.Revision), revision) {
return fmt.Errorf("%w: pipeline revision %q, approved %q", ErrPipelineMismatch, pipeline.VCS.Revision, revision)
}

target := repoFromURL(pipeline.VCS.TargetRepositoryURL)
origin := repoFromURL(pipeline.VCS.OriginRepositoryURL)
if origin != "" && target != "" && !strings.EqualFold(origin, target) {
return fmt.Errorf("%w: pipeline builds code from fork %s", ErrPipelineMismatch, origin)
}

expectedRepo := strings.TrimSpace(expect.Repo)
if expectedRepo == "" {
return nil
}
actual := target
if actual == "" {
actual = repoFromProjectSlug(pipeline.ProjectSlug)
}
if actual != "" && !strings.EqualFold(actual, expectedRepo) {
return fmt.Errorf("%w: pipeline repository %s, expected %s", ErrPipelineMismatch, actual, expectedRepo)
}
return nil
}

// repoFromURL returns "owner/repo" for a repository URL like https://github.com/owner/repo(.git).
func repoFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" {
return ""
}
parts := strings.Split(strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/"), "/")
if len(parts) != 2 {
return ""
}
return parts[0] + "/" + parts[1]
}

// repoFromProjectSlug returns "owner/repo" for VCS-style project slugs ("gh/owner/repo").
// GitHub App projects ("circleci/<org-id>/<project-id>") don't name the repository.
func repoFromProjectSlug(slug string) string {
parts := strings.Split(strings.TrimSpace(slug), "/")
if len(parts) != 3 {
return ""
}
switch parts[0] {
case "gh", "github", "bb", "bitbucket":
return parts[1] + "/" + parts[2]
default:
return ""
}
}
109 changes: 109 additions & 0 deletions internal/gateway/circleci/pipeline_verification_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package circleci

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"

"github.com/stretchr/testify/require"
)

const (
testWorkflowID = "11111111-2222-3333-4444-555555555555"
testPipelineID = "99999999-8888-7777-6666-555555555555"
approvedSHA = "0123456789abcdef0123456789abcdef01234567"
docspringRepo = "https://github.com/DocSpring/docspring"
)

type fakePipeline struct {
revision, targetRepo, originRepo, slug string
}

// newFakeCircleCI serves the workflow/pipeline/job endpoints ApproveJob uses and counts approvals.
func newFakeCircleCI(t *testing.T, pipeline fakePipeline) (*Client, *int32) {
t.Helper()
var approvals int32
write := func(w http.ResponseWriter, v interface{}) { _ = json.NewEncoder(w).Encode(v) }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/approve/"):
atomic.AddInt32(&approvals, 1)
write(w, map[string]string{"message": "Accepted."})
case r.URL.Path == "/workflow/"+testWorkflowID:
write(w, map[string]string{"id": testWorkflowID, "name": "deploy", "pipeline_id": testPipelineID})
case r.URL.Path == "/pipeline/"+testPipelineID:
vcs := map[string]string{
"revision": pipeline.revision,
"target_repository_url": pipeline.targetRepo,
"origin_repository_url": pipeline.originRepo,
}
write(w, map[string]interface{}{"id": testPipelineID, "project_slug": pipeline.slug, "vcs": vcs})
case r.URL.Path == "/pipeline/"+testPipelineID+"/workflow":
write(w, map[string]interface{}{"items": []map[string]string{{"id": testWorkflowID, "name": "deploy"}}})
case r.URL.Path == "/workflow/"+testWorkflowID+"/job":
write(w, map[string]interface{}{"items": []map[string]string{
{"id": "job-1", "name": "approve_deploy_us", "type": "approval"},
}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
client := NewClient("token")
client.BaseURL = server.URL
return client, &approvals
}

func TestApproveJobApprovesMatchingPipeline(t *testing.T) {
client, approvals := newFakeCircleCI(t, fakePipeline{
revision: approvedSHA, targetRepo: docspringRepo, originRepo: docspringRepo, slug: "gh/DocSpring/docspring",
})
err := client.ApproveJob(testWorkflowID, "100", "approve_deploy_us",
ApprovalExpectation{Revision: approvedSHA, Repo: "DocSpring/docspring"})
require.NoError(t, err)
require.EqualValues(t, 1, *approvals)
}

func TestApproveJobRefusesMismatchedPipelines(t *testing.T) {
cases := map[string]fakePipeline{
"different commit": {
revision: strings.Repeat("f", 40), targetRepo: docspringRepo, originRepo: docspringRepo,
},
"different repository": {
revision: approvedSHA, targetRepo: "https://github.com/attacker/docspring",
originRepo: "https://github.com/attacker/docspring",
},
"fork pipeline": {
revision: approvedSHA, targetRepo: docspringRepo, originRepo: "https://github.com/attacker/docspring",
},
"different project slug": {revision: approvedSHA, slug: "gh/attacker/other"},
}
for name, pipeline := range cases {
t.Run(name, func(t *testing.T) {
client, approvals := newFakeCircleCI(t, pipeline)
err := client.ApproveJob(testWorkflowID, "100", "approve_deploy_us",
ApprovalExpectation{Revision: approvedSHA, Repo: "DocSpring/docspring"})
require.ErrorIs(t, err, ErrPipelineMismatch)
require.EqualValues(t, 0, *approvals, "nothing may be approved on a mismatch")
})
}
}

func TestApproveJobRejectsNonUUIDWorkflowID(t *testing.T) {
client, approvals := newFakeCircleCI(t, fakePipeline{revision: approvedSHA})
for _, id := range []string{"test-workflow-1", "../pipeline/x", ""} {
err := client.ApproveJob(id, "100", "approve_deploy_us", ApprovalExpectation{Revision: approvedSHA})
require.ErrorIs(t, err, ErrInvalidWorkflowID)
}
require.EqualValues(t, 0, *approvals)
}

func TestParseMetadataRequiresUUIDWorkflowID(t *testing.T) {
_, err := ParseMetadata(map[string]interface{}{
"workflow_id": "not-a-uuid", "pipeline_number": "1", "approval_job_name": "hold",
})
require.ErrorIs(t, err, ErrInvalidWorkflowID)
}
22 changes: 22 additions & 0 deletions internal/gateway/db/commit_sha.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package db

import (
"regexp"
"strings"
)

var fullCommitSHAPattern = regexp.MustCompile(`^[0-9a-f]{40}$`)

// NormalizeCommitSHA trims and lowercases a git commit hash and reports whether it is a
// full 40-character SHA-1. Deploy approvals are bound to full commits only.
func NormalizeCommitSHA(value string) (string, bool) {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized, fullCommitSHAPattern.MatchString(normalized)
}

// likePrefixPattern escapes LIKE wildcards in prefix and appends % so it only matches values
// that start with the literal prefix. Use with "LIKE ? ESCAPE '\'".
func likePrefixPattern(prefix string) string {
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(prefix)
return escaped + "%"
}
Loading
Loading