docs: ADR 0029 — central token mint for secretless .fullsend#655
Conversation
Record proposed architecture: shared GitHub Apps, OIDC-bound token mint, workflow_call instead of dispatch PAT, and no long-lived secrets in the config repo. Status Proposed pending implementation and supersession of ADR 0007/0008 details. Co-authored-by: Cursor <cursoragent@cursor.com>
Review: #655Head SHA: 164aee4 SummaryClean, well-structured Proposed ADR adding a central token mint architecture to eliminate long-lived secrets from FindingsNo critical, high, medium, or low findings. Info
FooterOutcome: approve Previous runReview: #655Head SHA: fc89efb SummaryThis PR adds a well-structured Proposed ADR (0029) documenting the architecture direction for a central token mint and shared GitHub Apps, eliminating long-lived secrets from FindingsInfo
FooterOutcome: approve Previous run (2)Review: #655Head SHA: 1a69009 SummaryClean, well-structured Proposed ADR adding the central token mint architecture direction. The document correctly references and builds upon existing accepted ADRs (0007, 0008, 0025, 0026), all relative links resolve to files present on main, and the ADR number (0027) does not conflict with any existing ADR. The security analysis is substantive — it identifies the mint as a high-value target, discusses OIDC-based workload identity binding via FindingsNo critical, high, medium, low, or info findings. FooterOutcome: approve Previous run (3)Review: #655Head SHA: 1252fdb SummaryClean, well-structured Proposed ADR that follows the project template and accurately references existing ADRs (0007, 0008, 0025). The security consequences are thoughtfully articulated — particularly the mint-as-high-value-target and the FindingsMedium
Info
FooterOutcome: comment-only Previous run (4)Review: #655Head SHA: bece422 SummaryThis PR adds a well-written Proposed ADR describing a central token mint architecture for eliminating long-lived secrets from FindingsHigh
FooterOutcome: request-changes |
Merge upstream/main; ADR 0026 is now stage-based dispatch. Rename and re-title the secretless .fullsend ADR as 0027. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed review feedback and CI:
|
Address review: acknowledge interaction with stage-based dispatch when moving to workflow_call; extend follow-on ADR list; match template HTML comment under Status. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Updated ADR 0027 per latest review:
Also merged latest |
|
How do the mint service accepts orgs? I want to run my own mint service with my set of apps and I don't want anyone hooking to it. |
The OIDC token that the workflow sends into the mint contains, org, repo, and branch and workflow file path. If furthermore the workflow calls another workflow, we get those details for the called workflow in the job_workflow_ref claim. We can filter on any of those but I think our logic would be to:
After validation the mint needs to mint the app token, for that it needs an ap Id, the API to fetch that is: We would use the org and repo from the OIDC token there, which means that:
So to your question - of you have your own app and mint , I am either:
Given this, I'm not sure we really need to add an org or repo based whitelist to the service. Rather limits would be good on any API endpoint so we'll put those in place regardless. |
ADR 0027 (central token mint for secretless .fullsend) is being finalized in PR #655 by ifireball. This branch's implementation will reference that ADR once accepted. Signed-off-by: Wayne Sun <gsun@redhat.com>
Implementation Plan: Token Mint +
|
| ADR 0027 item | Status | Notes |
|---|---|---|
| Shared public Apps | Deferred | Per-org Apps remain. Mint architecture supports shared Apps — just change which PEMs are stored |
| Deployment profiles (multi-mint) | Deferred | Single GCF deployment. Handler is portable (standard HTTP, no GCP SDK in request path) |
| Cross-platform extensibility (Tekton) | Deferred | GitHub Actions OIDC only. Mint can add issuers later |
| Other SCM support | Deferred | GitHub only. Follows forge abstraction pattern |
| Follow-on ADRs superseding 0007/0008 | Deferred | Will reference this ADR once accepted |
Architecture
ENROLLED REPO .FULLSEND REPO
───────────── ──────────────
fullsend.yaml ── workflow_call ──────> dispatch.yml
(shim) │
┌──────┴──────┐
│ Fan-out │
└──────┬──────┘
┌─────────────────┼─────────────────┐
triage.yml code.yml review.yml
│ │ │
OIDC → mint OIDC → mint OIDC → mint
→ triage token → coder token → review token
TOKEN MINT (Cloud Function)
1. Validate OIDC JWT (WIF)
2. Check job_workflow_ref claim
3. Look up PEM for (org, role)
4. Mint scoped installation token
5. Return token + expiry
Token Mint API
POST /v1/token
Authorization: Bearer <GitHub OIDC JWT>
{"role": "coder", "repos": ["target-repo"]}
→ {"token": "ghs_xxxx", "expires_at": "..."}
Org derived from repository_owner claim — callers cannot request tokens for other orgs.
Key design decisions
- Token mint, not dispatch proxy — the mint only issues tokens. Fan-out logic stays in
dispatch.ymlwhere it belongs. Simpler function, easier to test, portable across FaaS. - Per-org PEM namespacing — secret names:
fullsend-{org}-{role}-app-pem. When shared Apps land, just swap which PEMs the mint holds. - Immediate PEM persistence —
StoreAgentPEM(org, role, pem)called right after each App creation via manifest flow, survives partial install failures. - Portable handler — HTTP handler has no GCP imports in the request path. Only the provisioner (deployment automation) is GCP-specific. Same handler deploys to Lambda, Cloud Run, or a container.
Gap analysis vs ADR 0027
The main gap is shared Apps as the default onboarding path. This plan keeps per-org Apps (ADR 0007) and implements the mint as infrastructure that makes shared Apps possible later. The question for this ADR: should Phase 1 be accepted as a valid partial implementation, or does the ADR expect shared Apps from day one?
PR #503 (OIDC dispatch proxy) will be rewritten to implement this plan directly — no transitional dispatch proxy merged.
Update (2026-05-07): PR #503 now implements this plan. Key changes since this comment:
- Mint moved from
internal/dispatch/gcf/function/tointernal/mint/ - Dispatch mode renamed from
oidc-gcftooidc-mint - Org is derived from the JWT
repository_ownerclaim (not env var) — validated againstALLOWED_ORGS - dispatch.yml uses OIDC mint for its own token in
workflow_callmode (no secrets flow through shim)
Correction: Deployment modelsAfter more thought — per-org Apps + shared mint is not a broken trust model. It's the bundled model: an enterprise admin managing multiple GitHub orgs runs one mint for all of them. Same admin controls both the Apps and the mint — no trust split. Three deployment models, same mint code:
How each works with `admin install`:
Per-org PEM namespacing (`fullsend-{org}-{role}-app-pem`) + WIF `repository_owner` claim isolation supports all three models. Phase 1 implements self-managed + bundled. SaaS deferred until shared Apps land. |
ADR 0027 (central token mint for secretless .fullsend) is being finalized in PR #655 by ifireball. This branch's implementation will reference that ADR once accepted. Signed-off-by: Wayne Sun <gsun@redhat.com>
ADR 0027 (central token mint for secretless .fullsend) is being finalized in PR #655 by ifireball. This branch's implementation will reference that ADR once accepted. Signed-off-by: Wayne Sun <gsun@redhat.com>
I don't fully understand how this enables the shared app mode, for that to work we would need to define a set of pems that are not namespaced to a particular org and setup some rule for when to use them (e.g. use shared pems when there are no org-specific ones). Or did you indend to have a separate copy of the shared PEMs for each org that uses them? |
Original (stale line numbers)@ifireball Good question — let me ground this in the actual code from PR #503. How PEMs are stored (provisioner) — func secretID(org, role string) string {
return fmt.Sprintf("fullsend-%s-%s-app-pem", strings.ToLower(org), role)
}How PEMs are looked up (mint) — name := fmt.Sprintf("projects/%s/secrets/fullsend-%s-%s-app-pem/versions/latest",
projectNumber, strings.ToLower(org), role)The For SaaS with shared Apps, there are two options: Option A — Separate copy per org (no code changes): The platform operator stores the same shared App PEM under each org's namespace: Option B — Fallback lookup (small code change): Add a My intent was Option A for simplicity — the PEM duplication is negligible (one Secret Manager entry per org, same key bytes) and avoids any special-case lookup logic. Update (2026-05-07): Corrected file paths and org derivation. The mint was refactored from |
ADR 0027 (central token mint for secretless .fullsend) is being finalized in PR #655 by ifireball. This branch's implementation will reference that ADR once accepted. Signed-off-by: Wayne Sun <gsun@redhat.com>
Renumber from 0027 to avoid collision with 0027-allowed-and-disallowed-tools. Merge upstream/main for a clean base. Address review: precise ADR 0026 wording, explicit ADR 0008 reversal, expanded security and availability consequences. Defer accepted-ADR status updates until this ADR is Accepted. Co-authored-by: Cursor <cursoragent@cursor.com>
|
fullsend review is working on this — view logs |
Checklist: follow-ups when ADR 0029 is Accepted (not part of this PR)Accepted ADRs stay immutable in Context / Decision / Consequences (ADR 0001, template). Only status, frontmatter Living document
Accepted ADRs — status-only + link (wording to refine at accept time)
This PR (for reviewers)
If anything in the “Suggested handling” column should use Superseded vs Accepted + note differently, that can be decided at merge-to- |
|
@fullsend-ai-review Pushed ADR 0029 ( @rh-hemartin The new Consequences spell out centralized mint blast radius and org-scoped installs; your mint-isolation question matches the deployment-profile / installation story already discussed in-thread. @waynesun09 FYI the Proposed ADR is now numbered 0029 (0027 on |
Site previewPreview: https://dbf52fe5-site.fullsend-ai.workers.dev Commit: |
|
@ifireball Agreed — after thinking through what the three profiles actually mean from an org admin's perspective, going with B (role-only PEM naming, no org prefix) makes more sense. The only thing it eliminates is the ability to have private per-org Apps in a shared mint, which is a scenario we don't need to support. PR #503 has been updated to implement this. Here's what changed: Provisioner — func secretID(role string) string {
return fmt.Sprintf("fullsend-%s-app-pem", role)
}Mint — name := fmt.Sprintf("projects/%s/secrets/fullsend-%s-app-pem/versions/latest",
s.gcpProjectNum, role)PEMAccessor interface — type PEMAccessor interface {
AccessPEM(ctx context.Context, role string) ([]byte, error)
}App slugs — The three profiles now look like this from an org admin's perspective:
The key simplification: since PEMs are role-only ( |
- ~20 line thin callers → ~40 lines (actual) - secrets: inherit → explicit secret passthrough (least-privilege) - workflow_call nesting limit 10 → 4 (GitHub actual limit) - ~60 scaffold files → ~80 (actual count) - Token generation: remove stale "transitional PEM" paragraph, reflect current OIDC mint-token implementation - ADR 0027 → ADR 0029 (token mint renumbered on PR #655) Signed-off-by: Wayne Sun <gsun@redhat.com>
Token mint ADR renumbered from 0027 to 0029 on PR #655. Signed-off-by: Wayne Sun <gsun@redhat.com>
Token mint ADR renumbered from 0027 to 0029 on PR #655. Signed-off-by: Wayne Sun <gsun@redhat.com>
waynesun09
left a comment
There was a problem hiding this comment.
The implementation in PR #503 defaults to private per-org apps with an opt-in --public flag for multi-org, which diverges from this ADR's decision statement ("public, shared GitHub Apps as the default"). Suggest updating the decision to reflect the implemented behavior:
Default: private apps, one set per org. This is the safer default — blast radius is contained to a single org's installations. The --public flag opts in to multi-org shared apps when needed.
Private → public is a one-click change. GitHub App visibility is toggled under Settings → Advanced → Danger zone → "Make public". No re-registration or PEM rotation needed. Once public, other orgs install via the app's installation URL. Verified on the nonflux test apps — the toggle is available. (Note: public apps cannot be made private again if installed on other accounts.)
Role-only PEM naming enables both modes. PEMs are stored as fullsend-{role}-app-pem (no org prefix), so a single PEM serves all orgs sharing the same app. Additional orgs using --mint-url require zero Secret Manager work — the PEMs are already there.
Suggested wording for the Decision section:
Adopt a central token mint and per-role GitHub Apps (private by default, optionally public for multi-org) as the default way to give Fullsend agents forge identity...
- Per-role Apps. Each agent role gets a dedicated GitHub App. Apps are private to the creating org by default. For multi-org deployments, apps can be made public (unlisted) via
--publicat install time or toggled in GitHub App settings afterward.
This aligns the ADR with what shipped and preserves the option to go public when orgs are ready.
This seems self contradictory, if, as you say, we implement the mint so that it can only store one set of apps (no org scoping for the PEMs) then only two modes are possible:
The so called "bundled" option of a central mint with multiple per-org private app sets cannot exist in this mode (Nor do I think we need it, if an org wants its own apps -> it should deploy its own mint). |
Move event-to-stage routing from the per-target-repo shim into dispatch.yml. The shim shrinks from 8 jobs (~380 lines) to 2 jobs (~50 lines). New stages require zero changes to enrolled repos. Sequenced after the token mint migration (ADR 0027 / PR fullsend-ai#655) which provides the workflow_call mechanism this ADR uses. Signed-off-by: Wayne Sun <gsun@redhat.com>
|
@ifireball You're right — the "bundled" concept doesn't hold up with role-only PEM naming. Dropping it. With
"Bundled" (one mint, multiple orgs, private apps) is not supported — and shouldn't be. Private apps can't be installed on other orgs, and role-only PEMs mean one mint can only hold one PEM per role. Two private orgs = two different app PEMs for the same role = doesn't fit in one mint. If one admin manages two orgs with private apps, they deploy two separate mints in different GCP projects. That's just self-managed applied twice, not a distinct mode. Infrastructure resource names ( Suggested update to the ADR: remove the three-tier deployment model (self-managed / bundled / SaaS) and describe two modes:
This matches what the implementation actually supports and avoids confusing readers with a "bundled" concept that doesn't exist. |
|
@ralphbean can you remove the "request changes" flag so we can merge this? |
ralphbean
left a comment
There was a problem hiding this comment.
The previous CHANGES_REQUESTED items are all addressed in the current head:
- ADR 0026 characterization is now precise (preserves dispatch intent, only auth assumptions change)
- ADR 0008 reversal is explicit — both in the Context paragraph and the new "Relationship to prior ADRs" section
- Renumbered to 0029 to avoid collision
- Consequences section now includes specific blast-radius and SPOF bullets
One deferred note inline on the "public, shared Apps as default" wording vs. the Phase 1 implementation (raised by @waynesun09). Not blocking — but should be resolved before this ADR is accepted.
|
|
||
| ## Decision | ||
|
|
||
| Adopt a **central token mint** and **public, shared GitHub Apps** as the default way to give Fullsend agents forge identity, so the `.fullsend` repository needs **no long-lived secrets** for LLM access or App private keys. |
There was a problem hiding this comment.
[moderate, deferred] The Decision section says "public, shared GitHub Apps" as the default, but the Phase 1 implementation (PR #503) ships private, per-org apps as the default, with --public as an opt-in flag. @waynesun09's review and implementation plan comment both flag "Shared public Apps" as explicitly deferred.
This isn't blocking for a Proposed ADR, but before acceptance the Decision wording should either:
- (a) Align with what shipped: "per-role GitHub Apps (private by default, optionally public for multi-org)" — @waynesun09 has suggested specific replacement wording in their review.
- (b) Be explicit about intent vs. current state: If shared public Apps are the architectural target and Phase 1 is a deliberate partial realization, say so here so future readers understand the delta.
There was a problem hiding this comment.
I have changed the language to explicitly state ultimate intended outcome, v.s. shipped intermediate states, and clarified language about multi-org mints to say that those would expose public apps (because an app cannot be multi-org otherwise) rather then multiple sets of private single-org apps.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
fullsend review is working on this — view logs |
waynesun09
left a comment
There was a problem hiding this comment.
ADR 0029 looks good. Ralph's deferred feedback on public/shared vs. private/per-org app wording is addressed — Decision section now clearly separates normative end-state from phased rollout reality, with self-managed as a first-class indefinite option.
Summary
Adds Proposed ADR 0029: shared GitHub Apps, OIDC-bound central token mint, no long-lived secrets in the
.fullsendrepo,workflow_callinstead of dispatch PAT, and notes on multi-mint deployments and extensibility (Tekton, other SCMs).Renumbered from 0026 after ADR 0026 landed on
main.Context
Captures the architecture direction discussed for eliminating repo-stored App PEMs and related deployment friction; does not change
docs/architecture.mdor supersede ADR 0007/0008 until the ADR is accepted.See #308 for a different attempt at dropping the need for the dispatch PAT.
Checklist
make lint(ADR linters + pre-commit) passes on this branch