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
74 changes: 61 additions & 13 deletions src/components/features/service-accounts/ExampleUsage.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
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,
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 the step the job adds; 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",
Expand All @@ -23,20 +29,62 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

const workflowIntro = (subject: string) => (
<>
In the repository <Code>{subject}</Code> names, save the full workflow under{" "}
<Code>.github/workflows/</Code>, 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: (
<>
Add to the job in <Code>repo:miskatonic/climate-data:ref:refs/heads/main</Code>, before it
uses the data:
</>
),
code: githubWorkflowStep("https://data.source.coop", "miskatonic--nightly-sync"),
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
* than the environment, and the trust wouldn't match.
*/
export const GithubWorkflowInAnEnvironment: Story = {
args: {
...GithubWorkflow.args,
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),
},
},
};

export const ApiKey: Story = {
args: {
title: "Sign in with HPC cron job",
Expand Down
23 changes: 19 additions & 4 deletions src/components/features/service-accounts/ExampleUsage.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -17,30 +18,44 @@ const COLOURS: Record<TokenKind, string | undefined> = {
/**
* 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,
}: {
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<string, string>;
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 (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content style={{ maxWidth: 720 }} aria-describedby={undefined}>
<Dialog.Title>{title}</Dialog.Title>
<Flex direction="column" gap="3">
<Text size="2">{intro}</Text>
{labels.length > 0 && (
<SegmentedControl.Root size="1" value={chosen} onValueChange={choose} style={{ alignSelf: "start" }}>
{labels.map((label) => (
<SegmentedControl.Item key={label} value={label}>
{label}
</SegmentedControl.Item>
))}
</SegmentedControl.Root>
)}
<Box position="relative">
<Box position="absolute" top="3" right="3">
<CopyToClipboard text={code} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, githubWorkflowStep } from "@/lib/services/service-account-usage";
import { IssuedApiKeyDialog } from "./IssuedApiKeyDialog";
import { ApiKeyList } from "./ApiKeyList";

Expand Down Expand Up @@ -110,10 +110,14 @@ function TrustRow({
title="Sign in from this workflow"
intro={
<>
Add to the job in <Code>{trust.subject}</Code>, before it uses the data:
In the repository <Code>{trust.subject}</Code> names, save the full workflow under{" "}
<Code>.github/workflows/</Code>, or add the step to a job of your own:
</>
}
code={githubWorkflowStep(proxyOrigin, accountId)}
code={{
"Full Workflow": githubWorkflow(proxyOrigin, accountId, trust.subject),
Step: githubWorkflowStep(proxyOrigin, accountId, trust.subject),
}}
language="yaml"
open={showingUsage}
onOpenChange={setShowingUsage}
Expand Down
59 changes: 50 additions & 9 deletions src/lib/services/service-account-usage.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,58 @@
import { apiKeyEnvironment, githubWorkflowStep } 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 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.*\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 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:");
});

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");
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"/);
});
});

Expand Down
82 changes: 62 additions & 20 deletions src/lib/services/service-account-usage.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,70 @@
/**
* What a GitHub Actions job adds to act as a service account: 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.
* 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.
*/
export function githubWorkflowStep(proxyOrigin: string, account_id: string): string {
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`: 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.
*/
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 }",
"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}`,
"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",
"",
"jobs:",
" data:",
" runs-on: ubuntu-latest",
...(environment ? [` environment: ${JSON.stringify(environment)}`] : []),
" permissions:",
" id-token: write",
" contents: read",
" steps:",
...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");
}

Expand Down
Loading