From e816b843ab85ca1be07927a12db654d8df23dde2 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 1 Oct 2026 16:12:51 -0700 Subject: [PATCH 1/3] fix(accounts): hand out a whole workflow file as a trusted workflow's example The example usage for a trusted GitHub workflow was a fragment of a job, `env:` and `steps:` at column zero with permissions in a comment, so pasting it into a workflow put the keys at the wrong level: at the top level `steps` is invalid, and under `jobs:` they become jobs of their own. It is now a complete workflow to save under .github/workflows/: one job with runs-on, `id-token: write`, the proxy's S3 endpoint, the sign-in step and a first `aws s3 ls`. The example is built from the trust's subject, so a trust pinned to an environment gets `environment:` on the job, without which GitHub puts the ref in the token's subject and the trust doesn't match; a trust pinned to a ref says which ref to run it on. Co-Authored-By: Claude Opus 5.5 --- .../service-accounts/ExampleUsage.stories.tsx | 36 +++++++++++-- .../service-accounts/ServiceAccountDetail.tsx | 7 +-- .../services/service-account-usage.test.ts | 30 +++++++++-- src/lib/services/service-account-usage.ts | 54 ++++++++++++------- 4 files changed, 97 insertions(+), 30 deletions(-) diff --git a/src/components/features/service-accounts/ExampleUsage.stories.tsx b/src/components/features/service-accounts/ExampleUsage.stories.tsx index 434ef032b..e1713d980 100644 --- a/src/components/features/service-accounts/ExampleUsage.stories.tsx +++ b/src/components/features/service-accounts/ExampleUsage.stories.tsx @@ -1,12 +1,12 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; import { Code } from "@radix-ui/themes"; import { ExampleUsage } from "./ExampleUsage"; -import { apiKeyEnvironment, githubWorkflowStep } from "@/lib/services/service-account-usage"; +import { apiKeyEnvironment, githubWorkflow } from "@/lib/services/service-account-usage"; /** * What software adds to sign in as a service account, ready to paste, opened * from "Example usage" in the menu on each way it signs in. For a trusted - * GitHub workflow it is the step the job adds; for a working API key, the + * GitHub workflow it is a whole workflow file; for a working API key, the * variables that point any AWS SDK or the AWS CLI at the key saved to a file. * Each names the account in the role ARN and points at the data proxy; * nothing in either is secret. Keys, values and comments are coloured, and @@ -28,15 +28,41 @@ export const GithubWorkflow: Story = { title: "Sign in from this workflow", intro: ( <> - Add to the job in repo:miskatonic/climate-data:ref:refs/heads/main, before it - uses the data: + Save under .github/workflows/ in the repository{" "} + repo:miskatonic/climate-data:ref:refs/heads/main names: ), - code: githubWorkflowStep("https://data.source.coop", "miskatonic--nightly-sync"), + code: githubWorkflow( + "https://data.source.coop", + "miskatonic--nightly-sync", + "repo:miskatonic/climate-data:ref:refs/heads/main" + ), language: "yaml", }, }; +/** + * A workflow trusted when it runs in a GitHub environment. The job names the + * environment, without which GitHub puts the ref in the token's subject rather + * than the environment, and the trust wouldn't match. + */ +export const GithubWorkflowInAnEnvironment: Story = { + args: { + ...GithubWorkflow.args, + intro: ( + <> + Save under .github/workflows/ in the repository{" "} + repo:miskatonic@8123456/climate-data@9456789:environment:production names: + + ), + code: githubWorkflow( + "https://data.source.coop", + "miskatonic--nightly-sync", + "repo:miskatonic@8123456/climate-data@9456789:environment:production" + ), + }, +}; + export const ApiKey: Story = { args: { title: "Sign in with HPC cron job", diff --git a/src/components/features/service-accounts/ServiceAccountDetail.tsx b/src/components/features/service-accounts/ServiceAccountDetail.tsx index 639923d4b..4efffda6f 100644 --- a/src/components/features/service-accounts/ServiceAccountDetail.tsx +++ b/src/components/features/service-accounts/ServiceAccountDetail.tsx @@ -37,7 +37,7 @@ import { AddSignInMenu } from "./AddSignInMenu"; import { GrantProductDialog } from "./GrantProductDialog"; import { ProductAccessList, type ProductAccess } from "./ProductAccessList"; import { ExampleUsage } from "./ExampleUsage"; -import { githubWorkflowStep } from "@/lib/services/service-account-usage"; +import { githubWorkflow } from "@/lib/services/service-account-usage"; import { IssuedApiKeyDialog } from "./IssuedApiKeyDialog"; import { ApiKeyList } from "./ApiKeyList"; @@ -110,10 +110,11 @@ function TrustRow({ title="Sign in from this workflow" intro={ <> - Add to the job in {trust.subject}, before it uses the data: + Save under .github/workflows/ in the repository{" "} + {trust.subject} names: } - code={githubWorkflowStep(proxyOrigin, accountId)} + code={githubWorkflow(proxyOrigin, accountId, trust.subject)} language="yaml" open={showingUsage} onOpenChange={setShowingUsage} diff --git a/src/lib/services/service-account-usage.test.ts b/src/lib/services/service-account-usage.test.ts index bd2715a21..31a2413ce 100644 --- a/src/lib/services/service-account-usage.test.ts +++ b/src/lib/services/service-account-usage.test.ts @@ -1,8 +1,32 @@ -import { apiKeyEnvironment, githubWorkflowStep } from "./service-account-usage"; +import { apiKeyEnvironment, githubWorkflow } from "./service-account-usage"; + +const REF = "repo:acme/data:ref:refs/heads/main"; +const ENVIRONMENT = "repo:acme@1/data@2:environment:production"; + +describe("githubWorkflow", () => { + // Pasted as-is, so the nesting is the contract: env, permissions and steps + // belong to the job, not to the workflow or to `jobs`. + it("is a whole workflow: one job, permitted to mint an OIDC token, with the sign-in step", () => { + const workflow = githubWorkflow("https://data.source.coop", "acme--nightly-sync", REF); + expect(workflow).toMatch(/^name: .+\non: workflow_dispatch {2}# run it on refs\/heads\/main/); + expect(workflow).toContain( + "\njobs:\n data:\n runs-on: ubuntu-latest\n permissions:\n id-token: write\n" + ); + expect(workflow).toContain("\n env:\n AWS_ENDPOINT_URL_S3: https://data.source.coop\n"); + expect(workflow).toContain( + "\n steps:\n - name: Sign in to Source Cooperative as acme--nightly-sync\n uses: aws-actions/configure-aws-credentials@v6\n" + ); + expect(workflow).toContain("\n - run: aws s3 ls s3://acme/"); + expect(workflow).not.toContain("environment:"); + }); + + it("names the environment a trust is pinned to, so the token's subject carries it", () => { + const workflow = githubWorkflow("https://data.source.coop", "acme--nightly-sync", ENVIRONMENT); + expect(workflow).toContain('\n runs-on: ubuntu-latest\n environment: "production"\n'); + }); -describe("githubWorkflowStep", () => { it("uses configure-aws-credentials against the proxy, naming the account in the role ARN", () => { - const step = githubWorkflowStep("https://data.source.coop", "nightly-sync"); + const step = githubWorkflow("https://data.source.coop", "nightly-sync", REF); expect(step).toContain("uses: aws-actions/configure-aws-credentials@v6"); // The action rebuilds any role that does not start with arn:aws as a bare // name, so the partition is aws whatever the proxy calls itself. diff --git a/src/lib/services/service-account-usage.ts b/src/lib/services/service-account-usage.ts index f121dddc8..9e1ecdafc 100644 --- a/src/lib/services/service-account-usage.ts +++ b/src/lib/services/service-account-usage.ts @@ -1,28 +1,44 @@ /** - * What a GitHub Actions job adds to act as a service account: the + * A whole GitHub Actions workflow that acts as a service account, ready to + * save under `.github/workflows/` as it is, for the trusted `subject`: the * `aws-actions/configure-aws-credentials` step, pointed at the data proxy's * STS endpoint and naming the account in `role-to-assume` — the account * segment of the ARN, as an AWS role's ARN names its account. The action - * mints the job's OIDC token for the proxy, exchanges it for credentials - * carrying the account's memberships if the account trusts the workflow's - * subject (ADR-014), and exports them for every later step; `env` points - * S3 clients at the proxy. The partition is `aws` because the action treats - * any other value as a bare role name. `FullAccess` is everything the account - * may do; `ReadOnly` narrows it to reads. + * mints the job's OIDC token for the proxy (which `id-token: write` permits), + * exchanges it for credentials carrying the account's memberships if the + * account trusts the workflow's subject (ADR-014), and exports them for every + * later step; `env` points S3 clients at the proxy. A subject pinned to an + * environment needs the job to name it, or the token's subject names the ref + * instead. The partition is `aws` because the action treats any other value + * as a bare role name. `FullAccess` is everything the account may do; + * `ReadOnly` narrows it to reads. */ -export function githubWorkflowStep(proxyOrigin: string, account_id: string): string { +export function githubWorkflow(proxyOrigin: string, account_id: string, subject: string): string { + const environment = subject.match(/:environment:(.+)$/)?.[1]; + const ref = subject.match(/:ref:(.+)$/)?.[1]; return [ - "# In the job, with permissions: { id-token: write }", - "env:", - ` AWS_ENDPOINT_URL_S3: ${proxyOrigin}`, - "steps:", - ` - name: Sign in to Source Cooperative as ${account_id}`, - " uses: aws-actions/configure-aws-credentials@v6", - " with:", - ` role-to-assume: arn:aws:iam::${account_id}:role/FullAccess`, - ` audience: ${proxyOrigin}`, - ` sts-endpoint: ${proxyOrigin}/.sts`, - " aws-region: us-west-2", + "name: Source Cooperative", + ref ? `on: workflow_dispatch # run it on ${ref}, the ref ${account_id} trusts` : "on: workflow_dispatch", + "", + "jobs:", + " data:", + " runs-on: ubuntu-latest", + ...(environment ? [` environment: ${JSON.stringify(environment)}`] : []), + " permissions:", + " id-token: write", + " contents: read", + " env:", + ` AWS_ENDPOINT_URL_S3: ${proxyOrigin}`, + " steps:", + ` - name: Sign in to Source Cooperative as ${account_id}`, + " uses: aws-actions/configure-aws-credentials@v6", + " with:", + ` role-to-assume: arn:aws:iam::${account_id}:role/FullAccess`, + ` audience: ${proxyOrigin}`, + ` sts-endpoint: ${proxyOrigin}/.sts`, + " aws-region: us-west-2", + ` # From here on, any AWS SDK or the AWS CLI acts as ${account_id}.`, + ` - run: aws s3 ls s3://${account_id.split("--")[0]}/`, ].join("\n"); } From 2b692bf56ef83fdc725deb80ff495cdf22df04b7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 2 Oct 2026 10:58:49 -0700 Subject: [PATCH 2/3] feat(accounts): switch a trusted workflow's example between the full workflow and the step The example usage for a trusted GitHub workflow now offers both forms behind a segmented control. "Full Workflow" sets AWS_ENDPOINT_URL_S3 once for the whole workflow; "Step" is the configure-aws-credentials step alone, setting AWS_ENDPOINT_URL_S3 on itself, with comments saying what the surrounding job needs. In both, `audience` and `sts-endpoint` read the proxy's address from `${{ env.AWS_ENDPOINT_URL_S3 }}` instead of repeating it. Co-Authored-By: Claude Opus 5.5 --- .../service-accounts/ExampleUsage.stories.tsx | 78 ++++++++++++------- .../service-accounts/ExampleUsage.tsx | 23 +++++- .../service-accounts/ServiceAccountDetail.tsx | 11 ++- .../services/service-account-usage.test.ts | 45 +++++++---- src/lib/services/service-account-usage.ts | 66 +++++++++++----- 5 files changed, 153 insertions(+), 70 deletions(-) diff --git a/src/components/features/service-accounts/ExampleUsage.stories.tsx b/src/components/features/service-accounts/ExampleUsage.stories.tsx index e1713d980..d9b3d8abc 100644 --- a/src/components/features/service-accounts/ExampleUsage.stories.tsx +++ b/src/components/features/service-accounts/ExampleUsage.stories.tsx @@ -1,16 +1,22 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; import { Code } from "@radix-ui/themes"; import { ExampleUsage } from "./ExampleUsage"; -import { apiKeyEnvironment, githubWorkflow } from "@/lib/services/service-account-usage"; +import { + apiKeyEnvironment, + githubWorkflow, + githubWorkflowStep, +} from "@/lib/services/service-account-usage"; /** * What software adds to sign in as a service account, ready to paste, opened * from "Example usage" in the menu on each way it signs in. For a trusted - * GitHub workflow it is a whole workflow file; for a working API key, the - * variables that point any AWS SDK or the AWS CLI at the key saved to a file. - * Each names the account in the role ARN and points at the data proxy; - * nothing in either is secret. Keys, values and comments are coloured, and - * the copy button in the block's corner takes all of it. + * GitHub workflow, a switch chooses between a whole workflow file and the + * sign-in step alone, to add to a job that already exists; for a working API + * key, it is the variables that point any AWS SDK or the AWS CLI at the key + * saved to a file. Each names the account in the role ARN and points at the + * data proxy; nothing in either is secret. Keys, values and comments are + * coloured, and the copy button in the block's corner takes all of whichever + * form is showing. */ const meta = { title: "Features/Service accounts/ExampleUsage", @@ -23,24 +29,46 @@ const meta = { export default meta; type Story = StoryObj; +const workflowIntro = (subject: string) => ( + <> + In the repository {subject} names, save the full workflow under{" "} + .github/workflows/, or add the step to a job of your own: + +); + +const REF = "repo:miskatonic/climate-data:ref:refs/heads/main"; +const ENVIRONMENT = "repo:miskatonic@8123456/climate-data@9456789:environment:production"; + +/** + * The whole workflow sets `AWS_ENDPOINT_URL_S3` for every job and step, and + * the sign-in step reads the proxy's address from it. + */ export const GithubWorkflow: Story = { args: { title: "Sign in from this workflow", - intro: ( - <> - Save under .github/workflows/ in the repository{" "} - repo:miskatonic/climate-data:ref:refs/heads/main names: - - ), - code: githubWorkflow( - "https://data.source.coop", - "miskatonic--nightly-sync", - "repo:miskatonic/climate-data:ref:refs/heads/main" - ), + intro: workflowIntro(REF), + code: { + "Full Workflow": githubWorkflow("https://data.source.coop", "miskatonic--nightly-sync", REF), + Step: githubWorkflowStep("https://data.source.coop", "miskatonic--nightly-sync", REF), + }, language: "yaml", }, }; +/** + * The step alone sets `AWS_ENDPOINT_URL_S3` on itself, which reaches no + * other step, so the comments above it say what the job around it needs. + */ +export const GithubWorkflowStep: Story = { + args: { + ...GithubWorkflow.args, + code: { + Step: githubWorkflowStep("https://data.source.coop", "miskatonic--nightly-sync", REF), + "Full Workflow": githubWorkflow("https://data.source.coop", "miskatonic--nightly-sync", REF), + }, + }, +}; + /** * A workflow trusted when it runs in a GitHub environment. The job names the * environment, without which GitHub puts the ref in the token's subject rather @@ -49,17 +77,11 @@ export const GithubWorkflow: Story = { export const GithubWorkflowInAnEnvironment: Story = { args: { ...GithubWorkflow.args, - intro: ( - <> - Save under .github/workflows/ in the repository{" "} - repo:miskatonic@8123456/climate-data@9456789:environment:production names: - - ), - code: githubWorkflow( - "https://data.source.coop", - "miskatonic--nightly-sync", - "repo:miskatonic@8123456/climate-data@9456789:environment:production" - ), + intro: workflowIntro(ENVIRONMENT), + code: { + "Full Workflow": githubWorkflow("https://data.source.coop", "miskatonic--nightly-sync", ENVIRONMENT), + Step: githubWorkflowStep("https://data.source.coop", "miskatonic--nightly-sync", ENVIRONMENT), + }, }, }; diff --git a/src/components/features/service-accounts/ExampleUsage.tsx b/src/components/features/service-accounts/ExampleUsage.tsx index b07328414..6f2b494be 100644 --- a/src/components/features/service-accounts/ExampleUsage.tsx +++ b/src/components/features/service-accounts/ExampleUsage.tsx @@ -1,6 +1,7 @@ "use client"; -import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes"; +import { useState } from "react"; +import { Box, Button, Dialog, Flex, SegmentedControl, Text } from "@radix-ui/themes"; import { CopyToClipboard } from "@/components/core/CopyToClipboard"; import { highlightLine, type Language, type TokenKind } from "./highlight"; @@ -17,12 +18,13 @@ const COLOURS: Record = { /** * What software adds to sign in one way, ready to paste — a workflow's step, * or the variables for a key — in a modal opened from "Example usage" in the - * row's menu. Nothing in it is secret. + * row's menu. Given several forms of it by label, a switch above the code + * chooses between them. Nothing in it is secret. */ export function ExampleUsage({ title, intro, - code, + code: forms, language, open, onOpenChange, @@ -30,17 +32,30 @@ export function ExampleUsage({ title: string; /** The line above the code: where it goes. */ intro: React.ReactNode; - code: string; + /** The snippet, or its forms keyed by the label that chooses each. */ + code: string | Record; language: Language; open: boolean; onOpenChange: (open: boolean) => void; }) { + const labels = typeof forms === "string" ? [] : Object.keys(forms); + const [chosen, choose] = useState(labels[0]); + const code = typeof forms === "string" ? forms : forms[chosen]; return ( {title} {intro} + {labels.length > 0 && ( + + {labels.map((label) => ( + + {label} + + ))} + + )} diff --git a/src/components/features/service-accounts/ServiceAccountDetail.tsx b/src/components/features/service-accounts/ServiceAccountDetail.tsx index 4efffda6f..f260bbcdb 100644 --- a/src/components/features/service-accounts/ServiceAccountDetail.tsx +++ b/src/components/features/service-accounts/ServiceAccountDetail.tsx @@ -37,7 +37,7 @@ import { AddSignInMenu } from "./AddSignInMenu"; import { GrantProductDialog } from "./GrantProductDialog"; import { ProductAccessList, type ProductAccess } from "./ProductAccessList"; import { ExampleUsage } from "./ExampleUsage"; -import { githubWorkflow } from "@/lib/services/service-account-usage"; +import { githubWorkflow, githubWorkflowStep } from "@/lib/services/service-account-usage"; import { IssuedApiKeyDialog } from "./IssuedApiKeyDialog"; import { ApiKeyList } from "./ApiKeyList"; @@ -110,11 +110,14 @@ function TrustRow({ title="Sign in from this workflow" intro={ <> - Save under .github/workflows/ in the repository{" "} - {trust.subject} names: + In the repository {trust.subject} names, save the full workflow under{" "} + .github/workflows/, or add the step to a job of your own: } - code={githubWorkflow(proxyOrigin, accountId, trust.subject)} + code={{ + "Full Workflow": githubWorkflow(proxyOrigin, accountId, trust.subject), + Step: githubWorkflowStep(proxyOrigin, accountId, trust.subject), + }} language="yaml" open={showingUsage} onOpenChange={setShowingUsage} diff --git a/src/lib/services/service-account-usage.test.ts b/src/lib/services/service-account-usage.test.ts index 31a2413ce..c15c0d86b 100644 --- a/src/lib/services/service-account-usage.test.ts +++ b/src/lib/services/service-account-usage.test.ts @@ -1,20 +1,21 @@ -import { apiKeyEnvironment, githubWorkflow } from "./service-account-usage"; +import { apiKeyEnvironment, githubWorkflow, githubWorkflowStep } from "./service-account-usage"; const REF = "repo:acme/data:ref:refs/heads/main"; const ENVIRONMENT = "repo:acme@1/data@2:environment:production"; describe("githubWorkflow", () => { - // Pasted as-is, so the nesting is the contract: env, permissions and steps - // belong to the job, not to the workflow or to `jobs`. - it("is a whole workflow: one job, permitted to mint an OIDC token, with the sign-in step", () => { + // Pasted as-is, so the nesting is the contract: env belongs to the workflow, + // permissions and steps to the job. + it("is a whole workflow: env for every step, one job permitted to mint an OIDC token, with the sign-in step", () => { const workflow = githubWorkflow("https://data.source.coop", "acme--nightly-sync", REF); - expect(workflow).toMatch(/^name: .+\non: workflow_dispatch {2}# run it on refs\/heads\/main/); + expect(workflow).toMatch( + /^name: .+\non: workflow_dispatch {2}# run it on refs\/heads\/main.*\nenv:\n {2}AWS_ENDPOINT_URL_S3: https:\/\/data.source.coop\n/ + ); expect(workflow).toContain( "\njobs:\n data:\n runs-on: ubuntu-latest\n permissions:\n id-token: write\n" ); - expect(workflow).toContain("\n env:\n AWS_ENDPOINT_URL_S3: https://data.source.coop\n"); expect(workflow).toContain( - "\n steps:\n - name: Sign in to Source Cooperative as acme--nightly-sync\n uses: aws-actions/configure-aws-credentials@v6\n" + "\n steps:\n - name: Sign in to Source Cooperative as acme--nightly-sync\n uses: aws-actions/configure-aws-credentials@v6\n with:\n" ); expect(workflow).toContain("\n - run: aws s3 ls s3://acme/"); expect(workflow).not.toContain("environment:"); @@ -26,16 +27,32 @@ describe("githubWorkflow", () => { }); it("uses configure-aws-credentials against the proxy, naming the account in the role ARN", () => { - const step = githubWorkflow("https://data.source.coop", "nightly-sync", REF); - expect(step).toContain("uses: aws-actions/configure-aws-credentials@v6"); + const workflow = githubWorkflow("https://data.source.coop", "nightly-sync", REF); + expect(workflow).toContain("uses: aws-actions/configure-aws-credentials@v6"); // The action rebuilds any role that does not start with arn:aws as a bare // name, so the partition is aws whatever the proxy calls itself. - expect(step).toContain("role-to-assume: arn:aws:iam::nightly-sync:role/FullAccess"); - expect(step).toContain("audience: https://data.source.coop"); - expect(step).toContain("sts-endpoint: https://data.source.coop/.sts"); - expect(step).toContain("AWS_ENDPOINT_URL_S3: https://data.source.coop"); + expect(workflow).toContain("role-to-assume: arn:aws:iam::nightly-sync:role/FullAccess"); + expect(workflow).toContain("audience: ${{ env.AWS_ENDPOINT_URL_S3 }}"); + expect(workflow).toContain("sts-endpoint: ${{ env.AWS_ENDPOINT_URL_S3 }}/.sts"); // Nothing account-specific beyond the id: no secret, no challenge. - expect(step).not.toMatch(/eyJ/); + expect(workflow).not.toMatch(/eyJ/); + }); +}); + +describe("githubWorkflowStep", () => { + it("is the sign-in step alone, setting the proxy on itself and reading it in `with`", () => { + const step = githubWorkflowStep("https://data.source.coop", "acme--nightly-sync", REF); + expect(step).toContain( + "\n- name: Sign in to Source Cooperative as acme--nightly-sync\n uses: aws-actions/configure-aws-credentials@v6\n env:\n AWS_ENDPOINT_URL_S3: https://data.source.coop\n with:\n" + ); + expect(step).toContain(" audience: ${{ env.AWS_ENDPOINT_URL_S3 }}\n"); + expect(step).not.toContain("jobs:"); + expect(step).not.toContain("environment:"); + }); + + it("says which environment the job must name when the trust is pinned to one", () => { + const step = githubWorkflowStep("https://data.source.coop", "acme--nightly-sync", ENVIRONMENT); + expect(step).toMatch(/^# In a job .* and environment: "production"/); }); }); diff --git a/src/lib/services/service-account-usage.ts b/src/lib/services/service-account-usage.ts index 9e1ecdafc..73cf675cb 100644 --- a/src/lib/services/service-account-usage.ts +++ b/src/lib/services/service-account-usage.ts @@ -1,17 +1,35 @@ +/** + * The `aws-actions/configure-aws-credentials` step that signs a GitHub Actions + * job in as a service account, pointed at the data proxy's STS endpoint and + * naming the account in `role-to-assume` — the account segment of the ARN, as + * an AWS role's ARN names its account. The action mints the job's OIDC token + * for the proxy (which `id-token: write` permits), exchanges it for + * credentials carrying the account's memberships if the account trusts the + * workflow's subject (ADR-014), and exports them for every later step. The + * audience and STS endpoint are read from `AWS_ENDPOINT_URL_S3`, the variable + * that also points S3 clients at the proxy, so the proxy is named once. The + * partition is `aws` because the action treats any other value as a bare role + * name. `FullAccess` is everything the account may do; `ReadOnly` narrows it + * to reads. + */ +const signInStep = (account_id: string, env: string[] = []) => [ + `- name: Sign in to Source Cooperative as ${account_id}`, + " uses: aws-actions/configure-aws-credentials@v6", + ...env, + " with:", + ` role-to-assume: arn:aws:iam::${account_id}:role/FullAccess`, + " audience: ${{ env.AWS_ENDPOINT_URL_S3 }}", + " sts-endpoint: ${{ env.AWS_ENDPOINT_URL_S3 }}/.sts", + " aws-region: us-west-2", +]; + /** * A whole GitHub Actions workflow that acts as a service account, ready to - * save under `.github/workflows/` as it is, for the trusted `subject`: the - * `aws-actions/configure-aws-credentials` step, pointed at the data proxy's - * STS endpoint and naming the account in `role-to-assume` — the account - * segment of the ARN, as an AWS role's ARN names its account. The action - * mints the job's OIDC token for the proxy (which `id-token: write` permits), - * exchanges it for credentials carrying the account's memberships if the - * account trusts the workflow's subject (ADR-014), and exports them for every - * later step; `env` points S3 clients at the proxy. A subject pinned to an + * save under `.github/workflows/` as it is, for the trusted `subject`: one job + * running the sign-in step, with `AWS_ENDPOINT_URL_S3` set for the whole + * workflow so every step's S3 client reaches the proxy. A subject pinned to an * environment needs the job to name it, or the token's subject names the ref - * instead. The partition is `aws` because the action treats any other value - * as a bare role name. `FullAccess` is everything the account may do; - * `ReadOnly` narrows it to reads. + * instead. */ export function githubWorkflow(proxyOrigin: string, account_id: string, subject: string): string { const environment = subject.match(/:environment:(.+)$/)?.[1]; @@ -19,6 +37,8 @@ export function githubWorkflow(proxyOrigin: string, account_id: string, subject: return [ "name: Source Cooperative", ref ? `on: workflow_dispatch # run it on ${ref}, the ref ${account_id} trusts` : "on: workflow_dispatch", + "env:", + ` AWS_ENDPOINT_URL_S3: ${proxyOrigin}`, "", "jobs:", " data:", @@ -27,21 +47,27 @@ export function githubWorkflow(proxyOrigin: string, account_id: string, subject: " permissions:", " id-token: write", " contents: read", - " env:", - ` AWS_ENDPOINT_URL_S3: ${proxyOrigin}`, " steps:", - ` - name: Sign in to Source Cooperative as ${account_id}`, - " uses: aws-actions/configure-aws-credentials@v6", - " with:", - ` role-to-assume: arn:aws:iam::${account_id}:role/FullAccess`, - ` audience: ${proxyOrigin}`, - ` sts-endpoint: ${proxyOrigin}/.sts`, - " aws-region: us-west-2", + ...signInStep(account_id).map((line) => ` ${line}`), ` # From here on, any AWS SDK or the AWS CLI acts as ${account_id}.`, ` - run: aws s3 ls s3://${account_id.split("--")[0]}/`, ].join("\n"); } +/** + * The sign-in step alone, for a workflow that already exists, with + * `AWS_ENDPOINT_URL_S3` set on the step itself. A step's `env` reaches only + * that step, so the comment above it says what the job needs around it. + */ +export function githubWorkflowStep(proxyOrigin: string, account_id: string, subject: string): string { + const environment = subject.match(/:environment:(.+)$/)?.[1]; + return [ + `# In a job with permissions: { id-token: write }${environment ? ` and environment: ${JSON.stringify(environment)}` : ""}.`, + "# Set AWS_ENDPOINT_URL_S3 on the job too for later steps to reach Source Cooperative.", + ...signInStep(account_id, [" env:", ` AWS_ENDPOINT_URL_S3: ${proxyOrigin}`]), + ].join("\n"); +} + /** * What a stock AWS SDK or the AWS CLI needs to sign in with an API key saved * to a file: it reads the file, exchanges the key at the proxy's STS endpoint From 11857a7a450a8eac2376626710baef37896657ea Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 2 Oct 2026 11:29:55 -0700 Subject: [PATCH 3/3] style(accounts): use the small segmented control for the example's switch Co-Authored-By: Claude Opus 5.5 --- src/components/features/service-accounts/ExampleUsage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/features/service-accounts/ExampleUsage.tsx b/src/components/features/service-accounts/ExampleUsage.tsx index 6f2b494be..1ea57eb30 100644 --- a/src/components/features/service-accounts/ExampleUsage.tsx +++ b/src/components/features/service-accounts/ExampleUsage.tsx @@ -48,7 +48,7 @@ export function ExampleUsage({ {intro} {labels.length > 0 && ( - + {labels.map((label) => ( {label}