Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/cli/mint.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ fullsend mint deploy \

The CLI automatically detects when the deployed function source is up-to-date (same source hash) and skips code redeployment, only updating WIF infrastructure and org registration.

Use `--public` to deploy a **public mint** (`ALLOWED_ORGS=*` with permissive WIF). Public mints accept any org that calls upstream reusable workflows in `fullsend-ai/fullsend`; org enrollment is not required. Unlike standalone JWKS mints, GCF-hosted public mints still need permissive WIF for the STS exchange path.
Use `--public` to deploy a **public mint** (`PER_REPO_WIF_REPOS=*` with permissive WIF). Public mints accept any org that calls upstream reusable workflows in `fullsend-ai/fullsend`; org enrollment is not required. Unlike standalone JWKS mints, GCF-hosted public mints still need permissive WIF for the STS exchange path.

Redeploying an existing mint must match its mode: pass `--public` for public mints, omit it for tight mints. Mode conversion (tight ↔ public) is rejected at deploy time.

Expand Down Expand Up @@ -106,7 +106,7 @@ Example: `--per-repo-wif-repos=` clears `PER_REPO_WIF_REPOS` without requiring `
| `--pem-dir` | | Directory containing `{role}.pem` files for PEM bootstrap |
| `--app-set` | `fullsend-ai` | App set name for PEM bootstrap |
| `--roles` | _(default roles)_ | Comma-separated role names to bootstrap with `--pem-dir`. Overrides the default set. Example: `--roles=fullsend,triage,coder,review,retro,prioritize,e2e` |
| `--public` | `false` | Deploy public mint (GCP: `ALLOWED_ORGS=*`; Cloudflare: `PER_REPO_WIF_REPOS=*`). Mutually exclusive with `--per-repo-wif-repos` on Cloudflare |
| `--public` | `false` | Deploy public mint (`PER_REPO_WIF_REPOS=*`). Mutually exclusive with `--per-repo-wif-repos` on Cloudflare |
| `--source-dir` | | Path to local mint source (default: checkout path when present, embedded otherwise) |
| `--dry-run` | `false` | Preview changes without making them |
| `--skip-deploy` | `false` | Skip code upload, reuse existing function (GCP only) |
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/infrastructure/infrastructure-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Mode is inferred from `ALLOWED_ORGS` — there is no separate trust-mode flag.
- **WORKFLOW_HOST_REPOS**: Same semantics as tight mode — controls which repos may host workflows. Defaults to `fullsend-ai/fullsend` when unset
- **mint enroll**: Succeeds without changing mint configuration (org registration is unnecessary); **mint unenroll** for individual orgs is rejected

**GCF mint (STS verification) only:** The hosted Cloud Function uses `STSVerifier`, which exchanges each OIDC JWT with GCP STS against `WIF_PROVIDER_NAME`. A permissive WIF provider (CEL that does not enumerate orgs/repos) must back that env var, or STS will reject tokens from orgs outside the provider's `attributeCondition` even when `mintcore` prevalidation passes. Use `mint deploy --public` to provision `ALLOWED_ORGS=*` and permissive WIF together; tight-mode `mint deploy` (default) and `mint enroll` continue to use org-scoped WIF. Redeploys must match the mint mode (`--public` for public, omit for tight).
**GCF mint (STS verification) only:** The hosted Cloud Function uses `STSVerifier`, which exchanges each OIDC JWT with GCP STS against `WIF_PROVIDER_NAME`. A permissive WIF provider (CEL that does not enumerate orgs/repos) must back that env var, or STS will reject tokens from orgs outside the provider's `attributeCondition` even when `mintcore` prevalidation passes. Use `mint deploy --public` to provision `PER_REPO_WIF_REPOS=*` and permissive WIF together; tight-mode `mint deploy` (default) and `mint enroll` continue to use org-scoped WIF. Redeploys must match the mint mode (`--public` for public, omit for tight).

**Standalone mint (JWKS verification):** `cmd/mint` uses `JWKSVerifier` — direct GitHub JWKS signature checks with no STS or WIF. Public mode is fully determined by `ALLOWED_ORGS` and workflow provenance in `mintcore`; WIF provisioning is not applicable.

Expand All @@ -124,7 +124,7 @@ Mode is inferred from `ALLOWED_ORGS` — there is no separate trust-mode flag.
A single mint instance can serve multiple orgs:

- **Tight mode:** `EnsureOrgInMint()` additively appends orgs to `ALLOWED_ORGS`
- **Public mode:** `ALLOWED_ORGS=*` — no per-org registration required; rollback to tight mode is config-only (replace `*` with an explicit org list)
- **Public mode:** `PER_REPO_WIF_REPOS=*` — no per-org registration required; rollback to tight mode is config-only (clear `PER_REPO_WIF_REPOS=*` and set an explicit org list)
- `ROLE_APP_IDS` maps `{role}` to GitHub App IDs (shared across all enrolled orgs)
- Org isolation at token issuance uses the OIDC `repository_owner` claim and GitHub App installation lookup — not per-org app ID entries

Expand Down
8 changes: 4 additions & 4 deletions docs/guides/infrastructure/mint-administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ The deploy command automatically detects when the deployed function is up-to-dat

### Public mint deployment

Use `--public` to bootstrap a public mint: `ALLOWED_ORGS=*` on the Cloud Function and a permissive WIF provider CEL for the STS authentication path. Orgs call the mint via upstream reusable workflows in `fullsend-ai/fullsend` after installing the shared public GitHub Apps — `mint enroll` is not required.
Use `--public` to bootstrap a public mint: `PER_REPO_WIF_REPOS=*` on the Cloud Function and a permissive WIF provider CEL for the STS authentication path. Orgs call the mint via upstream reusable workflows in `fullsend-ai/fullsend` after installing the shared public GitHub Apps — `mint enroll` is not required.

```bash
fullsend mint deploy --project="$GCP_PROJECT" --pem-dir=/path/to/pems --public
Expand All @@ -130,7 +130,7 @@ Redeploying or upgrading an existing mint must use the same mode: `--public` for
| `--pem-dir` | | Path to directory containing `{role}.pem` files for PEM bootstrap (GCP and Cloudflare) |
| `--app-set` | `fullsend-ai` | App set name for PEM bootstrap (used with `--pem-dir`) |
| `--roles` | _(default roles)_ | Comma-separated role names to bootstrap with `--pem-dir` |
| `--public` | `false` | Deploy public mint (GCP: `ALLOWED_ORGS=*`; Cloudflare: `PER_REPO_WIF_REPOS=*`); required to redeploy an existing public mint |
| `--public` | `false` | Deploy public mint (`PER_REPO_WIF_REPOS=*`); required to redeploy an existing public mint |
| `--source-dir` | | Path to local mint source directory (default: checkout path when present, embedded otherwise) |
| `--skip-deploy` | `false` | Skip code upload, reuse existing function (only update WIF/config) |
| `--dry-run` | `false` | Preview changes without making them |
Expand Down Expand Up @@ -293,9 +293,9 @@ Role PEM secrets and `ROLE_APP_IDS` must already exist on the mint, created duri

### Public mint mode

When the mint is configured with `ALLOWED_ORGS=*` (public mode), `mint enroll` exits successfully (exit code 0) in both public and tight modes, but only tight mode updates `ALLOWED_ORGS` and WIF. In public mode, org registration is unnecessary because all orgs are already allowed — the command discovers the mint and reports public mode without changing configuration. Scripts can call enroll in both modes without branching. `mint enroll owner/repo` also succeeds without per-repo WIF changes; per-repo installs use the default WIF provider and upstream reusable workflows.
When the mint is configured with `PER_REPO_WIF_REPOS=*` (public mode), `mint enroll` exits successfully (exit code 0) in both public and tight modes, but only tight mode updates `ALLOWED_ORGS` and WIF. In public mode, org registration is unnecessary because all orgs are already allowed — the command discovers the mint and reports public mode without changing configuration. Scripts can call enroll in both modes without branching. `mint enroll owner/repo` also succeeds without per-repo WIF changes; per-repo installs use the default WIF provider and upstream reusable workflows.

`mint unenroll` cannot remove individual orgs from a public mint. To restrict access, replace `ALLOWED_ORGS=*` with an explicit org list (config-only rollback; no PEM rotation required).
`mint unenroll` cannot remove individual orgs from a public mint. To restrict access, clear `PER_REPO_WIF_REPOS=*` and set an explicit org list (config-only rollback; no PEM rotation required).

### Post-enrollment verification

Expand Down
47 changes: 27 additions & 20 deletions internal/cli/mint.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,20 @@ func parseAllowedOrgs(allowedOrgs string) []string {
return orgs
}

func isPublicMintAllowedOrgs(allowedOrgs string) bool {
return mintcore.IsPublicMint(parseAllowedOrgs(allowedOrgs))
// isPublicMintRepos reports whether a PER_REPO_WIF_REPOS value indicates
Comment thread
ifireball marked this conversation as resolved.
// public mint mode (ADR-0078: PER_REPO_WIF_REPOS=* means any repo can call the mint).
func isPublicMintRepos(perRepoWIFRepos string) bool {
for _, entry := range mintcore.SplitCSV(perRepoWIFRepos) {
if entry == "*" {
return true
}
}
return false
}

// mintValidationMessage returns the success message after validating an existing mint.
func mintValidationMessage(trafficEnv map[string]string, envErr error) string {
if envErr == nil && isPublicMintAllowedOrgs(trafficEnv["ALLOWED_ORGS"]) {
if envErr == nil && isPublicMintRepos(trafficEnv["PER_REPO_WIF_REPOS"]) {
return "Mint validated (public mode — org registration not required)"
}
return "Mint validated and org registered"
Expand Down Expand Up @@ -571,7 +578,7 @@ Cloudflare mode (--platform=cloudflare):
cmd.Flags().StringVar(&rolesFlag, "roles", "", `comma-separated role names to bootstrap with --pem-dir
Overrides the default set (fullsend,triage,coder,review,retro,prioritize).
Example: --roles=fullsend,triage,coder,review,retro,prioritize,e2e`)
cmd.Flags().BoolVar(&public, "public", false, `deploy public mint (GCP: ALLOWED_ORGS=*; Cloudflare: PER_REPO_WIF_REPOS=*)
cmd.Flags().BoolVar(&public, "public", false, `deploy public mint (PER_REPO_WIF_REPOS=*)
Mutually exclusive with --per-repo-wif-repos on Cloudflare`)

// GCP-specific flags.
Expand Down Expand Up @@ -678,7 +685,7 @@ func runMintDeployGCP(ctx context.Context, project, region, sourceDir string, sk
printer.StepInfo("Would skip code deployment (--skip-deploy)")
}
if public {
printer.StepInfo("Would deploy public mint (ALLOWED_ORGS=*, permissive WIF)")
printer.StepInfo("Would deploy public mint (PER_REPO_WIF_REPOS=*, permissive WIF)")
}
if pemDir != "" {
if _, err := validatePEMDir(pemDir, roles); err != nil {
Expand Down Expand Up @@ -750,7 +757,7 @@ func runMintDeployGCP(ctx context.Context, project, region, sourceDir string, sk
summaryLines = append(summaryLines, fmt.Sprintf("App set: %s (PEMs bootstrapped)", appSet))
}
if public {
summaryLines = append(summaryLines, "Mode: public (ALLOWED_ORGS=*)")
summaryLines = append(summaryLines, "Mode: public (PER_REPO_WIF_REPOS=*)")
summaryLines = append(summaryLines, "Orgs may call this mint via upstream reusable workflows after installing shared Apps")
} else {
summaryLines = append(summaryLines, "Next: fullsend mint enroll <org> --project="+project)
Expand Down Expand Up @@ -1178,7 +1185,7 @@ func verifyEnrollment(ctx context.Context, printer *ui.Printer, provisioner enro

orgPresent := false
allowedOrgs := verifyEnvVars["ALLOWED_ORGS"]
if isPublicMintAllowedOrgs(allowedOrgs) {
if isPublicMintRepos(verifyEnvVars["PER_REPO_WIF_REPOS"]) {
orgPresent = true
} else {
for _, o := range strings.Split(allowedOrgs, ",") {
Expand All @@ -1190,8 +1197,8 @@ func verifyEnrollment(ctx context.Context, printer *ui.Printer, provisioner enro
}

if orgPresent {
if isPublicMintAllowedOrgs(allowedOrgs) {
printer.StepDone("Public mint mode (ALLOWED_ORGS=*) — all orgs allowed")
if isPublicMintRepos(verifyEnvVars["PER_REPO_WIF_REPOS"]) {
printer.StepDone("Public mint mode (PER_REPO_WIF_REPOS=*) — all orgs allowed")
} else {
orgCount := 0
for _, o := range strings.Split(allowedOrgs, ",") {
Expand Down Expand Up @@ -1245,9 +1252,9 @@ func runMintEnrollOrg(ctx context.Context, printer *ui.Printer, org, project, re
if err != nil {
return fmt.Errorf("reading mint env vars: %w", err)
}
if isPublicMintAllowedOrgs(trafficEnv["ALLOWED_ORGS"]) {
if isPublicMintRepos(trafficEnv["PER_REPO_WIF_REPOS"]) {
printer.Blank()
printer.StepInfo("Mint is in public mode (ALLOWED_ORGS=*) — org registration is not required")
printer.StepInfo("Mint is in public mode (PER_REPO_WIF_REPOS=*) — org registration is not required")
printer.Blank()
printer.Summary("Enrollment complete", []string{
fmt.Sprintf("Organization: %s", org),
Expand Down Expand Up @@ -1341,9 +1348,9 @@ func runMintEnrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, p
if err != nil {
return fmt.Errorf("reading mint env vars: %w", err)
}
if isPublicMintAllowedOrgs(trafficEnv["ALLOWED_ORGS"]) {
if isPublicMintRepos(trafficEnv["PER_REPO_WIF_REPOS"]) {
printer.Blank()
printer.StepInfo("Mint is in public mode (ALLOWED_ORGS=*) — per-repo WIF registration is not supported")
printer.StepInfo("Mint is in public mode (PER_REPO_WIF_REPOS=*) — per-repo WIF registration is not supported")
printer.StepInfo("Per-repo installs use the default WIF provider and upstream reusable workflows")
printer.Blank()
printer.Summary("Enrollment complete", []string{
Expand Down Expand Up @@ -1519,10 +1526,10 @@ func runMintUnenrollOrg(ctx context.Context, printer *ui.Printer, org, project,
if err != nil {
return fmt.Errorf("reading mint env vars: %w", err)
}
if isPublicMintAllowedOrgs(trafficEnv["ALLOWED_ORGS"]) {
if isPublicMintRepos(trafficEnv["PER_REPO_WIF_REPOS"]) {
printer.Blank()
printer.StepInfo("Mint is in public mode (ALLOWED_ORGS=*) — individual org unenroll is not supported")
printer.StepInfo("To restrict access, replace ALLOWED_ORGS=* with an explicit org list")
printer.StepInfo("Mint is in public mode (PER_REPO_WIF_REPOS=*) — individual org unenroll is not supported")
printer.StepInfo("To restrict access, clear PER_REPO_WIF_REPOS=* and set an explicit org list")
return nil
}

Expand Down Expand Up @@ -1613,9 +1620,9 @@ func runMintUnenrollRepo(ctx context.Context, printer *ui.Printer, repoFullName,
if err != nil {
return fmt.Errorf("reading mint env vars: %w", err)
}
if isPublicMintAllowedOrgs(trafficEnv["ALLOWED_ORGS"]) {
if isPublicMintRepos(trafficEnv["PER_REPO_WIF_REPOS"]) {
printer.Blank()
printer.StepInfo("Mint is in public mode (ALLOWED_ORGS=*) — per-repo unenroll is not supported")
printer.StepInfo("Mint is in public mode (PER_REPO_WIF_REPOS=*) — per-repo unenroll is not supported")
printer.StepInfo("Per-repo installs use the default WIF provider and upstream reusable workflows")
return nil
}
Expand Down Expand Up @@ -1871,11 +1878,11 @@ func runMintStatus(ctx context.Context, printer *ui.Printer, project, region, or
}
roleOnlyIDs := mintcore.RoleOnlyAppIDs(roleAppIDs)

publicMint := trafficEnv != nil && isPublicMintAllowedOrgs(trafficEnv["ALLOWED_ORGS"])
publicMint := trafficEnv != nil && isPublicMintRepos(trafficEnv["PER_REPO_WIF_REPOS"])
if publicMint {
printer.Blank()
printer.Header("Mint Mode")
printer.StepInfo(" Public (ALLOWED_ORGS=*)")
printer.StepInfo(" Public (PER_REPO_WIF_REPOS=*)")
}

if org != "" && !publicMint {
Expand Down
30 changes: 15 additions & 15 deletions internal/cli/mint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3417,16 +3417,16 @@ func TestParseAllowedOrgs_SkipsPlaceholder(t *testing.T) {
assert.Equal(t, []string{"acme", "widget"}, orgs)
}

func TestIsPublicMintAllowedOrgs(t *testing.T) {
assert.True(t, isPublicMintAllowedOrgs("*"))
assert.True(t, isPublicMintAllowedOrgs("org1,*"))
assert.False(t, isPublicMintAllowedOrgs("acme,widget"))
assert.False(t, isPublicMintAllowedOrgs(""))
func TestIsPublicMintRepos(t *testing.T) {
assert.True(t, isPublicMintRepos("*"))
assert.True(t, isPublicMintRepos("org/repo,*"))
assert.False(t, isPublicMintRepos("acme/repo,widget/repo"))
assert.False(t, isPublicMintRepos(""))
}

func TestMintValidationMessage(t *testing.T) {
assert.Equal(t, "Mint validated (public mode — org registration not required)",
mintValidationMessage(map[string]string{"ALLOWED_ORGS": "*"}, nil))
mintValidationMessage(map[string]string{"PER_REPO_WIF_REPOS": "*"}, nil))
assert.Equal(t, "Mint validated and org registered",
mintValidationMessage(map[string]string{"ALLOWED_ORGS": "acme"}, nil))
assert.Equal(t, "Mint validated and org registered",
Expand Down Expand Up @@ -3483,7 +3483,8 @@ func TestVerifyEnrollment_PublicMode(t *testing.T) {
printer := ui.New(out)
verifyEnrollment(context.Background(), printer, &fakeEnrollmentVerifier{
envVars: map[string]string{
"ALLOWED_ORGS": "*",
"ALLOWED_ORGS": "*",
"PER_REPO_WIF_REPOS": "*",
},
}, "any-org", "my-project")
assert.Contains(t, out.String(), "Public mint mode")
Expand Down Expand Up @@ -3549,21 +3550,21 @@ func publicMintDiscoveryClient() gcf.GCFClient {
gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{
URI: "https://mint.example.com",
EnvVars: map[string]string{
"ROLE_APP_IDS": `{"coder":"100","triage":"200"}`,
"ALLOWED_ORGS": "*",
"ROLE_APP_IDS": `{"coder":"100","triage":"200"}`,
"PER_REPO_WIF_REPOS": "*",
},
}),
gcf.WithFakeTrafficEnvVars(map[string]string{
"ROLE_APP_IDS": `{"coder":"100","triage":"200"}`,
"ALLOWED_ORGS": "*",
"ROLE_APP_IDS": `{"coder":"100","triage":"200"}`,
"PER_REPO_WIF_REPOS": "*",
}),
gcf.WithFakeRevisionInfo(&gcf.ServiceRevisionInfo{
TrafficRevisionShort: "fullsend-mint-00001",
TrafficPercent: 100,
TemplateMatchesTraffic: true,
TrafficEnvVars: map[string]string{
"ROLE_APP_IDS": `{"coder":"100","triage":"200"}`,
"ALLOWED_ORGS": "*",
"ROLE_APP_IDS": `{"coder":"100","triage":"200"}`,
"PER_REPO_WIF_REPOS": "*",
},
}),
)
Expand Down Expand Up @@ -3864,7 +3865,7 @@ func TestRunMintStatus_PublicMode(t *testing.T) {
printer := ui.New(out)
err := runMintStatus(context.Background(), printer, "my-project", "us-central1", "any-org")
require.NoError(t, err)
assert.Contains(t, out.String(), "Public (ALLOWED_ORGS=*)")
assert.Contains(t, out.String(), "Public (PER_REPO_WIF_REPOS=*)")
assert.Contains(t, out.String(), "public mode — all orgs")
assert.NotContains(t, out.String(), "not in ALLOWED_ORGS")
}
Expand Down Expand Up @@ -3982,7 +3983,6 @@ func TestRunMintUnenrollRepo_PublicMode(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, out.String(), "public mode")
assert.Contains(t, out.String(), "per-repo unenroll is not supported")
assert.NotContains(t, out.String(), "PER_REPO_WIF_REPOS")
}

func TestRunMintUnenrollRepo_Success(t *testing.T) {
Expand Down
Loading
Loading