Skip to content

feat(settings): mock service account creation form for #491 - #495

Closed
alukach wants to merge 14 commits into
mainfrom
feat/service-account-form-mock
Closed

alukach wants to merge 14 commits into
mainfrom
feat/service-account-form-mock

Conversation

@alukach

@alukach alukach commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

What this is

A non-persisting design mock of the service-account creation flow proposed in #491, so the data model can be reviewed in a browser before we build it.

Nothing is saved. Submitting the form renders the database rows the creation would write.

Where to find it

Settings → Service Accounts, on both individual and organization accounts (either can own a service account). Visible to anyone who can manage the account.

What it demonstrates

The four decisions from the epic, in order:

  1. Name — a human-friendly name; the stored id is derived from it and previewed live as svc--<sanitized>. Sanitization collapses double hyphens, so a name can never forge a second reserved prefix (ID_REGEX forbids -- in human account ids, which is what makes the prefix collision-proof).
  2. How software signs in — one or many. A GitHub repository + ref (no secret stored), or an API key that expires in 30/90/365 days or never. Every route reaches the same account with the same access.
  3. What it can reach — the whole owner account, or specific products, each read or read-write.
  4. Which roles it may use — Full access and Read only, both ticked by default. The form notes that both-ticked is a no-op and that unticking Full access is the durable restriction.

Submitting lands on a mock detail view of the integration — the page a real create would redirect to — with an Edit button that returns to the form with every answer intact. If a key sign-in method was chosen, the detail view shows the one-time API key with copy-now framing.

The detail view shows:

  • accounts (existing table) — the new service row with owner_account_id and allowed_roles
  • identity_bindings (new table) — one row per sign-in method, all pointing at the same principal
  • memberships (existing table) — one row per grant; account-wide access collapses to a single row with no repository_id, which is already how hasRole reads it
  • Ready-to-paste config per sign-in method: a GitHub Actions workflow using aws-actions/configure-aws-credentials@v4, or the CLI + env-var form for keys. The role in both changes if Full access is unticked.

Plus the caveats worth seeing next to the model: what doesn't exist yet, that roles only subtract, and that revoking a grant stops new access in ~60s while issued credentials last up to an hour.

Notes for review

  • plan.ts is pure and holds all the model logic; plan.test.ts covers it (14 tests) — namespacing, the GitHub sub claim shape, per-product vs account-wide membership rows, one binding per sign-in method, and the validation rules including the empty-role-set case that would leave an account unable to authenticate at all.
  • The page is gated on PutAccountProfile, not ListAccountMemberships — the latter returns true for any signed-in user (authz.ts:1242-1252), which would have exposed the mock on other people's accounts.
  • No API routes, no server actions, no schema changes. The only edits to existing files are a nav entry and a URL helper.

Two things the mock deliberately surfaces for decision

  • aws-actions/configure-aws-credentials also calls GetCallerIdentity, which /.sts does not implement. Since that action is how most people will first try this, the mock shows it and flags the gap.
  • Keys with no expiry are offered, but Epic: machine identity for unattended workflows #491 currently says keys cannot be issued without one. The mock calls this out as a departure rather than quietly picking a side.

Not included

Key issuance, revocation, the delete permission question, and custom roles. This is the create flow only.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt

Adds a non-persisting design mock of the service-account creation flow to
account and organization settings, so the data model proposed in #491 can be
reviewed before it is built.

The form walks the four decisions from the epic: name, how software signs in
(many per account, GitHub or API key), what it can reach (whole account or
specific products), and which roles it may use. Submitting renders the rows
the creation would write — an `accounts` row, one `identity_bindings` row per
sign-in method, one `memberships` row per grant — plus the environment
variables the workload would then use.

Nothing is saved. `planChanges` is pure and unit-tested; the page is gated on
the same account-management permission that creating a real service account
would require.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
@vercel

vercel Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
source-cooperative Ready Ready Preview Aug 24, 2026 4:22am

Request Review

@claude

claude Bot commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 1m 45s —— View job


Review

✅ No blocking issues — safe to merge.

This is a pure, client-side design mock with no persistence, no new API routes, and no schema changes, so the blast radius is small. I checked the areas that matter most for a mock like this:

  • Access gating (guard.ts:13-19, layout.tsx:130-137): correctly gated on Actions.PutAccountProfile, not ListAccountMemberships. Verified against authz.ts:1242-1252 (listAccountMemberships returns true for any signed-in, non-disabled user) vs. authz.ts:639-668 (putAccountProfile requires admin, account owner, or org owner/maintainer). The nav item visibility and the actual page load both use the same check, so there's no way to reach the page via a direct URL that the nav wouldn't have shown.
  • Reserved-id collision safety (plan.ts:83-93): sanitizeName collapses any run of non-[a-z0-9] characters (including repeated hyphens) into a single -, so svc--<name> can never contain a second --. Confirmed ID_REGEX (types/shared.ts:25) independently forbids -- in human-chosen ids, matching the comment's claim. Covered by the evil--thing test case in plan.test.ts:85-87.
  • plan.ts purity: no I/O, no mutation of shared state; planChanges/planDelete/planDisable are deterministic functions over their inputs, matching the "nothing persists" claim in the PR description.
  • XSS/injection surface: all user-controlled strings (name, repo, ref, product ids) are rendered through JSX text nodes / <Code>, never dangerouslySetInnerHTML or raw HTML — no injection vector despite fields like repository and ref being free text.
  • Mock API key (ServiceAccountForm.tsx:32-40): uses crypto.getRandomValues, generated and held only in client state, never transmitted — consistent with "nothing is saved."

No correctness bugs, security issues, or best-practice violations found in the diff.

Follow-up to the service-account mock:

- Submitting now lands on a mock detail view of the integration, with an Edit
  button that returns to the form with every answer intact.
- A one-time API key is shown on that page when a key sign-in method was
  chosen, with the "copy it now, it is only shown once" framing.
- The account id is derived from a human-friendly name rather than typed
  directly. Sanitization collapses double hyphens, so a name can never forge a
  second `svc--` prefix.
- API keys may now be issued with no expiry. #491 currently proposes that every
  key must expire, so the mock flags this as a deliberate departure to decide on.
- The GitHub Actions snippet uses aws-actions/configure-aws-credentials rather
  than hand-rolled environment variables, and notes that the action also calls
  GetCallerIdentity, which /.sts does not implement yet.
- Code samples render in a single <pre> with one container background instead
  of per-line inline code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
Restructures the service-account mock around the flow an admin would
actually use:

- The Service Accounts page is now a list of existing accounts — name and
  derived id, sign-in methods, access, roles, last authenticated, disabled
  state — with a New Service Account button.
- The form moved to /service-accounts/create.
- Each row links to /service-accounts/[name], a detail page whose Edit button
  reopens the form with that account's answers via ?prefill=.
- Editing an existing account exposes a danger zone: disable/enable, and
  delete. Confirming shows the rows the action would touch and, more usefully,
  when access actually stops — deletion does not recall credentials already
  issued, which live out their hour either way.
- Dropped the Owner field and summary row; it is implied by the account whose
  settings you are in. The ownership consequence (org-owned survives a member
  leaving) is kept as a note under the name.

Three fabricated accounts back the list, including one restricted to Read only
and one disabled, so both states are reviewable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
…dary

The list page passed `detailHref` as a function to a client component, which
Next.js refuses to serialize: "Functions cannot be passed directly to Client
Components". Each row now arrives with its own `href`, resolved on the server
via the existing `serviceAccountUrl` helper, so URL construction stays in
lib/urls rather than being rebuilt in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
…ails>

Everything a real screen would not show — the rows a submit would write, the
caveats, the "this isn't wired up" disclaimer — now sits inside a collapsed
<details>, so the list, form and detail pages read as the product rather than
as an annotated diagram.

Native <details> rather than a Radix accordion: it opens without JavaScript
and is keyboard- and screen-reader-accessible for free. Each summary line
still says "Design mock" while collapsed, so nothing reads as real at a
glance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
…rows

A table row forced each account's sign-in methods and product grants into a
single truncated cell ("2 products", "GitHub"), which hid exactly the detail
the mock exists to show. Cards give each account room to list every sign-in
method with its repository and ref, every product grant with its permission,
and its roles — without abbreviating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
There is no longer a separate page per service account. Each card in the list
carries everything the detail page did: sign-in methods, grants, roles, a
collapsed "How to use this" with the workload config, and the collapsed rows
behind it. The only remaining navigation is Edit, which opens the form
prefilled.

Edit, Disable/Enable and Delete now live in a Manage dropdown in the card's
top right, replacing the standalone danger zone. Confirming still reports the
rows the action would touch and when access actually stops.

Also drops the generated `svc--` id from the UI. It is an implementation
detail of the reserved namespace, not something an operator needs to read; it
remains visible in the collapsed model rows where it is actually relevant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
The collapsed "rows behind this service account" disclosure sat between the
card's real content and its footer, which put mock scaffolding in the middle
of a screen meant to read as the product. It is now a modal opened from a
Mock Details entry at the bottom of the Manage dropdown, separated from the
real actions.

The dialog scrolls at 80vh, since a service account with several grants and
sign-in methods produces more rows than fit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
… stdout

The "How to use this" disclosure is now a modal, opened by a Usage example
link on each card, so the card body carries only the account's own facts.

Reworks the API-key snippet on two points:

- The key is stored once in the OS keychain and read from there by the CLI,
  rather than being exported into the environment. The one-time key callout
  now says the same at the moment you'd store it.
- `source-coop token` writes to stdout and the caller redirects it, so the
  path in AWS_WEB_IDENTITY_TOKEN_FILE is the operator's choice. The CLI makes
  no assumption about where the token belongs. The snippet also notes the
  token is short-lived and the SDK re-reads the file, so refreshing it needs
  no restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
AWS_ENDPOINT_URL_S3 sits alongside the AWS_ENDPOINT_URL_STS already in the
snippets, so every command in the example inherits the endpoint rather than
each one carrying --endpoint-url. That also removes the line continuation the
YAML example needed.

Renames the placeholder audience from `source-cooperative` to
`source-data-proxy`, matching the convention CI already uses for the GitHub
issuer, and comments what the claim is for — a token minted for anyone else,
real AWS included, is rejected at /.sts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
A hostname names the intended recipient unambiguously: it cannot collide with
another issuer's tokens, it separates environments for free
(data.staging.source.coop will not be accepted at prod), and it matches the
prevailing convention — AWS uses sts.amazonaws.com.

`source-data-proxy` was an internal codename that meant nothing in a workflow
file and would have needed environment suffixes bolted on, as CI's
source-data-proxy-ci already does.

Drops the explanatory comment above the claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
@alukach

alukach commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Some design concepts for the cards:

Main@1x StatusRail@1x SpecSheet@1x

Going with option C for now.

…rection

Applies direction C from the design canvas. A tinted identity band carries
the icon, name, status and controls; the body is a three-column grid with
generous spacing; a hairline footer holds the timestamps.

Everything is expressed in Radix theme variables rather than fixed colors, so
the card follows light and dark mode. Two deliberate departures from stock
Radix, both because the theme sets `radius: "none"`:

- Status is a square dot plus a small-caps word, not a soft Badge — Radix's
  tinted badges read as rounded pills and fight square corners.
- Values a machine reads (repository, ref, product, role) render as plain
  mono text in --code-font-family rather than tinted `Code` chips, which at
  this density turned the card into a field of grey blocks.

Also fixes the bucket in both usage snippets: clients address products
path-style as s3://{account}/{product}/, not the registry's internal
account:product bucket name, which never appears in a client URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
The theme runs at `scaling: "110%"`, so every Radix size step is multiplied:
`size="1"` renders at 13.2px where the design calls for 11px, and `size="2"`
at 15.4px. The card came out uniformly inflated — labels competing with
values, and mono details rendering at the same size as the text they belong
under, which is what made it read as congested.

Sets the rendered sizes directly through a TYPE constant (label 11, value
14.5, mono value 13, mono detail 12.5, chip 11, meta 11, action 14), and
spaces the header, body and footer at the design's padding rather than the
nearest scaled step. Adds a gap between a sign-in method's name and its
repository lines so the detail sits under the value instead of running into
it.

Also switches Manage from `soft` to `surface`: on the tinted identity band a
soft button reads as a filled grey block rather than the outlined control the
design uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
…line

Usage example drops highContrast on every card, not just disabled ones. At
gray-12 it competed with the account name beside it; the muted grey reads as
the secondary control it is.

A sign-in method now always renders as a value with its attribute beneath —
GitHub over its repository and ref, API key over its expiry — instead of the
key folding its expiry into the value line with an em dash. The expiry keeps
the sans face since it is prose rather than a machine identifier, but shares
the muted colour and size of the mono attributes beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
alukach added a commit that referenced this pull request Sep 24, 2026
Closes #547. Part of #491. Stacked on #566, now merged, so the diff is
this branch alone: the feature commit, the immutable-subject commit, two
review-fix commits, a redesign of the form and the list, a fix for
writing trusts, owner-namespaced ids, and an account page that is where
creating lands and where grants are edited, with the create form's
product list, showing what it reaches and granting more from a new row.

## What

The real version of the flow #495 mocked, built on the model (#563), the
membership pages (#564) and account trusts (#566). Rebuilt on the trust
model in one commit; what review changed follows as commits of its own.

**Settings → Service Accounts** appears for individuals and
organizations alike, to whoever `canManageAccount` says manages the
account. The list page loads each service account with its trusts, its
grants and (from #570) its keys.

**`ServiceAccountForm`** — who it is (the name, and the id derived from
it, shown as code with an Edit link the way the data-connection form
shows its id), how software signs in (any number of GitHub workflows,
each a card with the repository on its own row — `owner/repo`, or
`owner@id/repo@id` for the immutable subjects GitHub mints for
repositories created after July 2026 or opted in — then a
Ref/Environment segmented control and its value, and the exact subject
shown as you type), what it may reach (the products it reaches, each
with Read / Read and write and a red X, and **Grant a product**, which
adds a row with a dropdown of the owner's other products (each option
the product's name over its `owner/product` path in small monospace), a
Read / Read and write choice, and a green check that finalizes it,
beside an X that cancels — the same `ProductAccessList` on the create
form and the account's page; each title opens the product in a new tab,
and the form holds its grants until it is submitted). **The id is
namespaced under the owner:** `{owner}--{id}`, such as
`acme--nightly-sync`, joined by the `--` account-owned data connections
already use. Two owners can each have a `nightly-sync`, a service
account never takes a handle a person or organization might want (their
ids cannot contain `--`), and no service-account id can equal an Ory
identity id, which is a UUID. The form asks for the short id and shows
the whole one; the action composes it. `ServiceAccountSchema` requires
the composed form, and a membership's member id accepts it, which
granting a namespaced service account a product needs. The general
`createAccount` action no longer takes `type=service`: nothing posted
it, and it created service accounts without the owner checks or the
namespace. Submitting writes the account, its grants and its trusts, and
redirects to the account's page, where each trusted workflow's example
usage is a click away. Nothing has to run first, and the step carries
nothing that expires.

**`ServiceAccountList`** — one row per account in the data connections'
own `ConnectionList`/`ConnectionRow`: the name linking to the account's
page, its id, a marker when it is disabled, and how many workflows it
trusts and products it reaches.

**`ServiceAccountDetail`**, at
`/edit/account/{owner}/service-accounts/{id}` — the account's page: the
workflows it trusts as rows, each removed with a red X (captioned
"Remove" on hover, labelled with the subject for screen readers), each
with an **Example usage** link that opens the step the workflow adds in
a modal, and the "Trust a GitHub workflow" dialog on the section header,
which writes the trust and closes onto the new row; the products it
reaches, each with Read / Read and write and a red X, and **Grant a
product**, which adds a row with a dropdown of the owner's other
products (each option the product's name over its `owner/product` path
in small monospace), a Read / Read and write choice, and a green check
that finalizes it, beside an X that cancels — the same
`ProductAccessList` on the create form and the account's page, here
**saved as each is made** (the check, a change of access, or an X),
shown at once (`useOptimistic`) with the controls held while it saves;
each title opens the product in a new tab so a viewer can check what it
holds; and Disable and Delete set apart in a danger zone. The page
resolves the account through `managedServiceAccount` and 404s unless it
is under its own owner. The actions revalidate the list and the page.
Delete confirms in an alert dialog whose submit runs the action (a plain
button, not `AlertDialog.Action`, which would close the dialog before
the action ran) and shows the action's message if it refuses; deleting
removes the grants and the account with its trusts, then redirects to
the list. The trust dialog's form lives inside `Dialog.Content`, so each
open starts fresh. The page passes the data-proxy origin down for the
example; without one, no example is offered.

**The workflow snippet** (`src/lib/services/github-workflow.ts`) is the
`aws-actions/configure-aws-credentials@v6` step pointed at the data
proxy — `sts-endpoint`, `audience` = the proxy origin — naming the
account in `role-to-assume:
arn:aws:iam::<service-account-id>:role/FullAccess`, plus `env` pointing
S3 clients at the proxy. From then on any S3 client in the job works, if
the account trusts the workflow's subject. **Depends on
developmentseed/multistore#126:** the action validates the credentials
it exports with `GetCallerIdentity`, which the proxy answers once that
lands.

**`AccountInfoHoverCard`**, the card an account name opens on hover
anywhere in the app, marks a service account with the outlined "Service
account" badge the picker and memberships table already use.

**What this PR adds to the model:** `canManageServiceAccount(session,
account, owner)` — whoever manages the owner manages the service
account, answered through `canManageAccount` on the fetched owner, so an
admin is included, a disabled owner takes its service accounts out of
reach with it (admins aside), and nothing that is not a service account
qualifies; a disabled service account is still managed so it can be
re-enabled or deleted. `managedServiceAccount(session, id)` in
`src/lib/accounts/service-accounts.ts` is the one resolver the lifecycle
actions (and, in #570, the key actions) go through: it loads the account
and its owner and answers null for anything not managed. And
`AccountTrustsTable.create` (schema-validated, conditional,
`AlreadyTrustedError`) and `delete`, which were in #566 until review
noted nothing there called them.

**Server actions** (`src/lib/actions/service-accounts.ts`):
`createServiceAccount`, `addGithubTrust`, `removeTrust`,
`setServiceAccountDisabled`, `deleteServiceAccount`, and for grants
`setProductAccess`. Create loads the owner and requires an enabled
individual or organization the caller manages — settled before any of
its products are read, so the form can't be used to probe another
account's catalogue — deduplicates the workflows and grants it was
posted. The rest go through `managedServiceAccount`; `setProductAccess`
also applies `serviceAccountGrantProblem` (read or write, the owner's
products only), changes the grant the account already has rather than
adding a second, touches only the account's own grants, and on none
revokes by setting the membership Revoked, as a person's is. No action
builds a workflow step: the page does, from the proxy origin. A disabled
account takes no new trust, and a repeat is named for what it is.
`removeTrust` deletes by the account's own key, so it can never touch
another account's.

## Stories

On this branch's deploy:

- `ServiceAccountForm` › **Default** —
https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountform--default
(submitting redirects in the app; Storybook cannot follow that, so the
mocked action just resolves)
- `ServiceAccountForm` › **NoProducts** —
https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountform--no-products
- `ServiceAccountList` › **Default**, **Disabled**, **Empty** —
https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--default
- `AccountInfoHoverCard` › **ServiceAccount** —
https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/components-accounts-accountinfohovercard--service-account
(hover the name)
- `ProductAccessList` › **NothingGranted**, **SomeGranted**,
**EverythingGranted**, **Saving**, **NoProducts** —
https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-productaccesslist--some-granted
(controlled, so every control works, Grant a product included)
- `ServiceAccountDetail` › **Default**, **Disabled**, **Empty** —
https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--default
("Example usage" opens the step in a modal; "Grant a product" adds the
row to choose a product and its access; an access change shows while the
mocked save runs, then springs back, since the mock saves nothing)
- `WorkflowSnippet` › **Default**, **ImmutableSubject** —
https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-workflowsnippet--default
- `GithubWorkflowFields` › **Empty**, **ImmutableRepositoryWithRemove**
—
https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-githubworkflowfields--empty
(controlled, so the stories hold the workflow in state and the fields
can be typed into)

The form, the detail and the dialog use `useActionState`, so they are on
the smoke test's cannot-render-under-jest list alongside the other
action-state forms; `WorkflowSnippet`, `GithubWorkflowFields` and
`ProductAccessList` render there. The stories and the Storybook mock
build the step with `githubWorkflowStep` rather than carrying a copy of
it.

## Screenshots

The create form: the id derived from the name, one workflow card, and
each product's access chosen in place:

![The New Service Account form: Name "Nightly Sync" with the account id
nightly-sync shown beneath it and an Edit link; a workflow card with
Repository miskatonic/climate-data, a note on the immutable
owner@123/repo@456 form, a trash button, a Ref / Environment segmented
control on Ref with refs/heads/main, and the text Trusts
repo:miskatonic/climate-data:ref:refs/heads/main; then Climate Data set
to Read and write and Reference Data set to
Read](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-form-redesign.png)

The list, one row per account:

![Two rows in a bordered list: Nightly Sync, nightly-sync, 1 workflow ·
1 product; Archive Mirror, archive-mirror, cannot sign in · 0 products;
each with a
chevron](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-list-rows.png)

The id, derived from the name under the owner, and the same id opened
for editing:

![Account ID: miskatonic--nightly-upload, made from the name Nightly
Upload, with an Edit
link](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-id-derived.png)

![The Account ID field open for editing: the prefix miskatonic-- set
flush against the editable nightly-upload, in one monospace
face](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-id-edit.png)

An account's page, where creating one lands. Each trusted workflow has
Example usage; the products it reaches are listed with their access and
an X, with Grant a product below:

![Nightly Sync, miskatonic--nightly-sync; Signs in as, two workflows
each with Example usage and a red X; Can reach, Climate Data set to Read
and write and Reference Data set to Read, each with a red X, then a
Grant a product button; Danger zone, with Disable and
Delete](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-detail-grants-v2.png)

After Grant a product: a row to choose the product and its access,
finalized with the check:

![Below the two granted products, a row with Field Notes chosen in a
dropdown, Read and write selected, a green check and a grey
X](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-grant-row-check.png)

A trust row, with the Remove caption showing on the X:

![A trust row for repo:miskatonic/climate-data:ref:refs/heads/main with
Example usage and a red X button, hovered, captioned
Remove](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-trust-remove-x.png)

The grant row's dropdown, each product's name over its path:

![The Choose a product dropdown open, listing Climate Data over
miskatonic/climate-data, Reference Data over miskatonic/reference-data,
and Field Notes over
miskatonic/field-notes](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-grant-dropdown.png)

The create form uses the same list and grant row, and holds its grants
until it is submitted.

Example usage, opened from a workflow's row:

![A modal titled Sign in from this workflow: add to the job in
repo:miskatonic/climate-data:ref:refs/heads/main the
configure-aws-credentials step whose role-to-assume is
arn:aws:iam::miskatonic--nightly-sync:role/FullAccess, with a copy
button and
Close](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-example-usage.jpg)

`WorkflowSnippet` on its own, with a subject in the immutable form
pinned to an environment — the block scrolls sideways rather than
wrapping the YAML:

![Add to the job in
repo:miskatonic@8123456/climate-data@9456789:environment:production,
before it uses the data, followed by the configure-aws-credentials step
for archive-mirror and a copy
button](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/workflow-snippet-immutable.jpg)

`GithubWorkflowFields` as the create form stacks it, an immutable
repository name pinned to an environment, with the Remove button:

![Repository miskatonic@8123456/climate-data@9456789, Pinned to An
environment, Environment production, a Remove button, and the text
Trusts
repo:miskatonic@8123456/climate-data@9456789:environment:production with
the note on naming the repository the way its tokens
do](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/github-workflow-fields-immutable.jpg)

A service account's hover card:

![A hover card for Nightly Sync, @miskatonic--nightly-sync, with an
outlined Service account
badge](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-hover-card-badge.png)

## Testing

- `npx jest` — all suites pass, re-run after the redesign.
`service-accounts.test.ts`: create writes the account, grants and trusts
once each however many times a field was posted, and redirects to the
account's page; refuses an unpinned workflow, a product the owner lacks
and a bad role before writing; refuses a non-manager, a missing owner
and a service-account owner, and reports a taken id on the short-id
field; delete removes grants then the account and redirects to the list;
a short id containing its own `--` is refused; disable/enable; remove a
trust by the account's key; trust one more workflow, refusing an
unpinned subject, a repeat and a disabled account; every lifecycle
action refuses what `managedServiceAccount` does not hand back.
`accounts/service-accounts.test.ts`: the resolver hands back the account
only when it is a service account whose owner is found and managed.
`github-workflow.test.ts`: the step names the account and the proxy and
carries nothing that expires.
- `authz.test.ts`: the manager rules — admin-over-organization refused,
some other account offered as the owner refused, a disabled owner
refused for its managers and allowed for an admin.
`account-trusts.test.ts`: write keyed by account and identity, only
once; `AlreadyTrustedError`; malformed rows refused before the table;
delete by the same key.
- `service-accounts.test.ts`, `setProductAccess`: a product the account
does not reach is granted as a member of the owner; one it reaches has
its grant changed rather than a second added, and none revokes it (and
is a no-op where there is no grant); a product the owner lacks, a role
beyond read or write, and a non-manager are refused before any write;
only the account's own grants are read.
- `account-trusts.test.ts` also asserts the create condition names
`identity` by placeholder. Unit tests mock DynamoDB, so they could not
catch the reserved word; it surfaced when a trust write failed on a
deployment with "Attribute name is a reserved keyword; reserved keyword:
identity".
- `npm run type-check`, `next lint`, `npm run build-storybook` — clean.

## Docs and ADRs

data.source.coop: ADR-014 (source-cooperative/data.source.coop#232)
describes this flow as amended with #566, and is amended again for the
namespaced id (commit ae85ff72 on that PR), which replaces its "no
reserved id namespace". docs.source.coop: the unattended-workflow guide
(source-cooperative/docs.source.coop#34) will carry the workflow step
this issues; it should read the way the AWS one does.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@alukach

alukach commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Overtaken by #567

@alukach alukach closed this Oct 9, 2026

This branch was successfully deployed

1 active deployment
Preview — 81456064 Deployed Aug 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant