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
128 changes: 128 additions & 0 deletions docs/ADRs/0082-workflow-host-allow-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---
title: "82. Separate workflow-host allow-list from caller allow-list"
status: Accepted
relates_to:
- agent-infrastructure
- security-threat-model
topics:
- mint
- identity
- oidc
- least-privilege
---

# 82. Separate workflow-host allow-list from caller allow-list

Date: 2026-08-04

## Status

Accepted

Builds on [ADR 0078](0078-simplified-mint-authorization-policy.md).

## Context

Mint trust previously tangled two distinct allow-lists:

1. **Callers** — which orgs/repos may request tokens (`ALLOWED_ORGS`,
`PER_REPO_WIF_REPOS`, and related enrollment paths).
2. **Workflow hosts** — which repos may appear in `job_workflow_ref` as
the source of workflows that call the mint.

The coupling made it hard to allow a repo to *obtain* tokens without also
treating it as a trusted place to *host* minting workflow code (and the
reverse). For example, per-repo callers listed in `PER_REPO_WIF_REPOS`
were automatically accepted as workflow hosts via `ValidateWorkflowRef`,
which checked the caller's own repository as a valid workflow source.

Installs that vendor reusable workflows into a consumer repo (e.g.
`github setup --vendor`) change `job_workflow_ref` to that consumer repo.
Those workflows cannot mint unless the consumer is explicitly listed as a
workflow host — which was previously only possible by adding it to
`PER_REPO_WIF_REPOS`, conflating caller enrollment with workflow-host
trust.

## Decision

### Separate controls

Introduce `WORKFLOW_HOST_REPOS`, a new environment variable listing repos
whose workflows are trusted to call the mint in per-repo mode. This is
independent of `ALLOWED_ORGS` and `PER_REPO_WIF_REPOS`.

### Per-repo mode

Per-repo callers (those in `PER_REPO_WIF_REPOS`) have their
`job_workflow_ref` validated against `WORKFLOW_HOST_REPOS`. The upstream
repo (`fullsend-ai/fullsend`) is always accepted. When
`WORKFLOW_HOST_REPOS` is not set, it defaults to `fullsend-ai/fullsend`
only.

### Per-org mode

Per-org callers (those whose `repository_owner` is in `ALLOWED_ORGS`)
have their `job_workflow_ref` hard-wired to two sources:
- The caller's own org `.fullsend` config repo (`{org}/.fullsend`)
- The upstream `fullsend-ai/fullsend` repo

No separate allow-list is consulted. This matches the operational model
where per-org installs rely on `{org}/.fullsend` as their workflow host.

### Dual enrollment

When a caller is both an enrolled repo (`PER_REPO_WIF_REPOS`) and its
org is an enrolled org (`ALLOWED_ORGS`), **both** workflow-ref validation
modes apply. The workflow may come from:
- Per-repo sources: any repo in `WORKFLOW_HOST_REPOS` (plus upstream)
- Per-org sources: `{org}/.fullsend` config repo (plus upstream)

The handler tries per-org validation first, then falls back to per-repo
validation. If either succeeds, the workflow ref is accepted. Scope
treatment uses per-org mode (the superset) — dual enrollment only
expands the set of accepted workflow hosts.

### Public mode

Public mode (`PER_REPO_WIF_REPOS=*`) uses the same per-repo validation
path — `WORKFLOW_HOST_REPOS` and the `ALLOWED_WORKFLOW_FILES` basename
gate both apply. The only difference between public and tight per-repo
mode is caller enrollment: `PER_REPO_WIF_REPOS=*` means every repo is
accepted as a caller without explicit listing.

> **Note:** [ADR 0059](0059-public-mint-mode-with-wildcard-allowlists.md)
> dropped the basename gate for public mode ("Basename gate: that restriction
> was dropped"). This ADR supersedes that exception: public mode now
> applies `ALLOWED_WORKFLOW_FILES` via the shared per-repo validation path.

### CLI and status surfaces

- `fullsend mint workflow-host add|remove|list` manages the
`WORKFLOW_HOST_REPOS` env var on the mint.
- `fullsend mint status` displays the effective workflow-host allow-list.
- `GET /v1/status` includes `workflow_host_repos` in the response.

## Consequences

- Operators can grant a repo caller access (via `PER_REPO_WIF_REPOS`)
without implicitly trusting it as a workflow host. Workflow-host trust
requires an explicit `WORKFLOW_HOST_REPOS` entry or the use of upstream
workflows.
- Vendored workflow installs (`--vendor`) require a one-time admin action
to add the vendored-workflow repo to `WORKFLOW_HOST_REPOS` before those
workflows can mint tokens.
- Existing per-repo callers that previously relied on hosting their own
workflows (via the old `ValidateWorkflowRef` logic that accepted the
caller's own repo from `PER_REPO_WIF_REPOS`) must either switch to
upstream workflows or be added to `WORKFLOW_HOST_REPOS`.
- Per-org callers see no behavior change — their workflow host validation
was already restricted to `.fullsend` and upstream.
- The default `WORKFLOW_HOST_REPOS` value (`fullsend-ai/fullsend`) matches
the previous behavior for callers using upstream workflows.

### Related ADRs

| Topic | ADR |
|-------|-----|
| Simplified mint authorization policy | [0078](0078-simplified-mint-authorization-policy.md) |
| Public mint mode (basename gate exception superseded above) | [0059](0059-public-mint-mode-with-wildcard-allowlists.md) |
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ Identity is not the same as trust. An agent's identity lets it authenticate to e
- Per-role GitHub Apps with manifest-based creation. Each agent role gets its own app with scoped permissions. PEMs stored in Secret Manager as `fullsend-{role}-app-pem` — one secret per role, shared across orgs on a mint. `ROLE_APP_IDS` uses the same shared-per-role model (`coder` → app ID). Org isolation is enforced via `ALLOWED_ORGS`, WIF conditions, and installation verification ([ADR 0007](ADRs/0007-per-role-github-apps.md), [ADR 0033](ADRs/0033-per-repo-installation-mode.md)). Public multi-tenant mint (`ALLOWED_ORGS=*`) with upstream-only workflow provenance is defined in [ADR 0059](ADRs/0059-public-mint-mode-with-wildcard-allowlists.md); upstream-only provenance limits which workflows can call the mint, complementing [ADR 0029](ADRs/0029-central-token-mint-secretless-fullsend.md) multi-tenant blast-radius concerns.
- Cross-org mint authorization: workflows may request tokens for a different org via optional `target_org` when the target org installs the role App and sets `FULLSEND_FOREIGN_<role>_REPOS` ([ADR 0060](ADRs/0060-cross-org-mint-authorization-via-org-variables.md)).
- Mint `repos` scope: foreign mints require empty `repos` (or `["*"]` as an empty alias). Per-repo callers (repo in `PER_REPO_WIF_REPOS`) must list exactly the requesting repository. Per-org callers (org in `ALLOWED_ORGS`, repo not in `PER_REPO_WIF_REPOS`) get org-mode shapes: `.fullsend` callers may use any non-empty validated list; other callers may use `[.fullsend]` or `{self,.fullsend}`. Same-org installation-wide tokens are denied ([ADR 0077](ADRs/0077-mint-repos-scope-hardening.md), simplified in [ADR 0078](ADRs/0078-simplified-mint-authorization-policy.md)).
- Workflow-host allow-list: `WORKFLOW_HOST_REPOS` controls which repos may host workflows calling the mint for per-repo and public-mode callers (default: `fullsend-ai/fullsend`). Per-org callers hard-wire to `{org}/.fullsend` and upstream. Public mode is not special-cased — it uses the same per-repo validation path with `WORKFLOW_HOST_REPOS` and the basename allowlist. This separates caller enrollment from workflow-host trust ([ADR 0082](ADRs/0082-workflow-host-allow-list.md)).
- Standalone mint deployment: `cmd/mint/` provides a self-contained HTTP server that uses direct JWKS verification and filesystem PEM storage instead of GCP infrastructure. It shares the `internal/mintcore/` library with the GCF mint and adds support for custom role permissions and a fallback proxy to an upstream mint. Custom role permissions live in mintcore (not `cmd/mint/`) so that `RolePermissionsFor`, `HasRole`, and `CreateInstallationToken` return a unified view without callers needing to distinguish built-in from custom roles. The GCF mint never calls `RegisterCustomRolePermissions`, so the code is inert there. See the [standalone mint guide](guides/infrastructure/standalone-mint.md).
- Hosted public community mint: steady-state deployment on Cloudflare Workers (JWKS + WAF + single ops console), with interim GCP Cloud Function acceptable until the Worker port is production-ready. Trust policy (`ALLOWED_ORGS=*`, upstream-only workflow provenance) is in [ADR 0059](ADRs/0059-public-mint-mode-with-wildcard-allowlists.md); deployment, edge security, monitoring, and phasing are in [ADR 0068](ADRs/0068-public-community-mint-architecture.md). Enrollment is installing the shared Apps—no per-org mint env registration ([#1145](https://github.com/fullsend-ai/fullsend/issues/1145)).
- Named privilege levels: each role defines ordered named levels (`read`, `write`), where each level's permissions are a superset of preceding levels. `read` for built-in roles is derived by downgrading `*:write` permissions to their `read` counterparts. The mint API accepts an optional `level` field (default `read`); omitting it produces narrower tokens than the current behavior. `write` is defined as the current max permission set for each built-in role. `CUSTOM_ROLE_PERMISSIONS` auto-detects a multi-level JSON shape alongside the existing flat format, with mixed format supported per role. The harness `privilege_levels` flag maps run-stages to levels; omitting it defaults to `write`, preserving backward compatibility for existing harness configurations ([ADR 0073](ADRs/0073-named-mint-privilege-levels.md)).
Expand Down
41 changes: 41 additions & 0 deletions docs/cli/mint.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Deploy and manage the OIDC token mint service. The mint exchanges GitHub Actions
| `fullsend mint remove-role <role>` | Remove a role from the mint |
| `fullsend mint enroll <org\|owner/repo>` | Register an org or repo in the mint |
| `fullsend mint unenroll <org\|owner/repo>` | Remove an org or repo from the mint |
| `fullsend mint workflow-host add <owner/repo>` | Add a repo to the workflow-host allow-list |
| `fullsend mint workflow-host remove <owner/repo>` | Remove a repo from the workflow-host allow-list |
| `fullsend mint workflow-host list` | List the workflow-host allow-list |
| `fullsend mint status [org]` | Inspect mint state and PEM health |
| `fullsend mint token` | Mint a short-lived token via OIDC (for testing) |

Expand Down Expand Up @@ -156,6 +159,44 @@ fullsend mint unenroll <org|owner/repo> \
--region "us-central1"
```

## `mint workflow-host`

Manages the `WORKFLOW_HOST_REPOS` allow-list that controls which repositories may host workflows calling the mint for per-repo callers. Per-org callers are not affected.

### `mint workflow-host add`

```bash
fullsend mint workflow-host add <owner/repo> \
--project "<GCP_PROJECT>" \
--region "us-central1"
```

Idempotent — skips repos already listed.

### `mint workflow-host remove`

```bash
fullsend mint workflow-host remove <owner/repo> \
--project "<GCP_PROJECT>" \
--region "us-central1"
```

### `mint workflow-host list`

```bash
fullsend mint workflow-host list \
--project "<GCP_PROJECT>" \
--region "us-central1"
```

Read-only — makes no changes.

| Flag | Default | Description |
|------|---------|-------------|
| `--project` | | GCP project ID (required) |
| `--region` | `us-central1` | Cloud region for the mint service |
| `--dry-run` | `false` | Preview changes without making them (`add` and `remove` only) |

## `mint status`

Inspects the mint's current state: deployed function, registered roles, enrolled orgs, and PEM health.
Expand Down
17 changes: 10 additions & 7 deletions docs/guides/infrastructure/infrastructure-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This guide provides implementation details for fullsend's infrastructure compone

## Token Mint (OIDC)

> Managed by: `fullsend mint deploy`, `fullsend mint enroll`, `fullsend mint unenroll`, `fullsend mint status`, `fullsend mint add-role`, `fullsend mint remove-role`, `fullsend mint token`
> Managed by: `fullsend mint deploy`, `fullsend mint enroll`, `fullsend mint unenroll`, `fullsend mint status`, `fullsend mint add-role`, `fullsend mint remove-role`, `fullsend mint workflow-host`, `fullsend mint token`

The mint exchanges GitHub OIDC tokens for scoped GitHub App installation tokens. This eliminates long-lived PATs from the system. The mint can be deployed on GCP (Cloud Function) or Cloudflare (Worker) — see `fullsend mint deploy --platform`.

Expand Down Expand Up @@ -39,8 +39,8 @@ The mint exchanges GitHub OIDC tokens for scoped GitHub App installation tokens.
│ │ ├─ Extract repository_owner → ALLOWED_ORGS check │ │
│ │ │ (explicit org list, or * for public mint mode) │ │
│ │ └─ Validate job_workflow_ref provenance │ │
│ │ (tight: .fullsend / upstream / per-repo; │ │
│ │ public: upstream fullsend-ai/fullsend only) │ │
│ │ (per-org: .fullsend / upstream; │ │
│ │ per-repo/public: WORKFLOW_HOST_REPOS) │ │
│ │ │ │
│ │ 2. STS Token Exchange │ │
│ │ ├─ POST securitytoken.googleapis.com │ │
Expand Down Expand Up @@ -98,15 +98,18 @@ Mode is inferred from `ALLOWED_ORGS` — there is no separate trust-mode flag.

- **ALLOWED_ORGS**: Only listed orgs may mint tokens
- **ALLOWED_WORKFLOW_FILES**: Fail-closed allowlist of workflow filenames (use `*` to allow any basename)
- **job_workflow_ref validation**: `.fullsend` config repo, `fullsend-ai/fullsend` upstream reusables, or registered per-repo workflows (`PER_REPO_WIF_REPOS`)
- **job_workflow_ref validation (per-org callers)**: `{org}/.fullsend` config repo or `fullsend-ai/fullsend` upstream reusables
- **job_workflow_ref validation (per-repo callers)**: Only repos listed in `WORKFLOW_HOST_REPOS` (defaults to `fullsend-ai/fullsend`)
- **job_workflow_ref validation (dual-enrolled callers)**: Callers matching both `PER_REPO_WIF_REPOS` and `ALLOWED_ORGS` accept workflows from **either** per-org sources (`{org}/.fullsend`, upstream) or per-repo sources (`WORKFLOW_HOST_REPOS`, upstream)
- **WORKFLOW_HOST_REPOS**: Comma-separated repos whose workflows are trusted to call the mint for per-repo callers. Managed via `fullsend mint workflow-host add|remove|list`. Defaults to `fullsend-ai/fullsend` when unset.
- **PER_REPO_WIF_REPOS**: Repos using dedicated WIF providers (repo-scoped isolation)

**Public mint**: `ALLOWED_ORGS` is `*`.

- **ALLOWED_ORGS**: Any org may mint (cross-org isolation still enforced at installation lookup)
- **job_workflow_ref validation**: Only `fullsend-ai/fullsend/.github/workflows/` (any ref — tag, branch, or SHA)
- **PER_REPO_WIF_REPOS**: Leave unset or empty (GCF mint: all repos use `WIF_PROVIDER_NAME`)
- **ALLOWED_WORKFLOW_FILES**: Basename gate is not applied in public mode
- **job_workflow_ref validation**: Same as per-repo callers — only repos listed in `WORKFLOW_HOST_REPOS` (defaults to `fullsend-ai/fullsend`). `ALLOWED_WORKFLOW_FILES` basename gate applies ([ADR 0082](../../ADRs/0082-workflow-host-allow-list.md) §2, revised 2026-08-05)
- **PER_REPO_WIF_REPOS**: Set to `*` for public mode (GCF mint: all repos use `WIF_PROVIDER_NAME`)
- **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).
Expand Down
39 changes: 39 additions & 0 deletions docs/guides/infrastructure/mint-administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ This guide covers deploying and managing the fullsend token mint. The mint is th
| `mint remove-role` | Remove an agent role from the mint (deletes PEM secret by default) |
| `mint enroll` | Register an org or repo in `ALLOWED_ORGS` and configure WIF |
| `mint unenroll` | Remove an org or repo from the mint |
| `mint workflow-host add` | Add a repo to the workflow-host allow-list |
| `mint workflow-host remove` | Remove a repo from the workflow-host allow-list |
| `mint workflow-host list` | List the workflow-host allow-list |
| `mint status` | Inspect mint health, enrolled orgs, and PEM secrets |
| `mint token` | Exchange a GitHub Actions OIDC token for an installation token |

Expand Down Expand Up @@ -325,6 +328,42 @@ Org-scoped unenroll removes the org from mint env vars and the shared WIF provid
| `--dry-run` | `false` | Preview changes without making them |
| `--yolo` | `false` | Skip interactive confirmation (for automation) |

## Managing workflow hosts

`fullsend mint workflow-host` manages the `WORKFLOW_HOST_REPOS` environment variable, which controls which repositories may host workflows that call the mint for per-repo callers. Per-org-only callers are not affected — they hard-wire to `{org}/.fullsend` and the upstream `fullsend-ai/fullsend` repo. Dual-enrolled callers (listed in both `PER_REPO_WIF_REPOS` and `ALLOWED_ORGS`) accept workflows from **either** per-repo sources (`WORKFLOW_HOST_REPOS`) or per-org sources (`{org}/.fullsend`, upstream).

When `WORKFLOW_HOST_REPOS` is not set, it defaults to `fullsend-ai/fullsend`.

### Adding a workflow host

```bash
fullsend mint workflow-host add acme-corp/my-workflows --project="$GCP_PROJECT"
```

Idempotent — skips repos already listed.

### Removing a workflow host

```bash
fullsend mint workflow-host remove acme-corp/my-workflows --project="$GCP_PROJECT"
```

### Listing workflow hosts

```bash
fullsend mint workflow-host list --project="$GCP_PROJECT"
```

Read-only — makes no changes.

### Flags

| Flag | Default | Description |
|------|---------|-------------|
| `--project` | | GCP project ID (required) |
| `--region` | `us-central1` | Cloud region for the mint service |
| `--dry-run` | `false` | Preview changes without making them (`add` and `remove` only) |

## Checking mint status

`fullsend mint status` inspects the deployed mint function, Cloud Run revision state, enrolled orgs, and PEM health. This is a read-only operation requiring only viewer-level access.
Expand Down
Loading
Loading