feat(accounts): manage service accounts from account settings - #567
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @alukach's task in 2m 23s —— View job Reviewing PR #567
✅ No blocking issues — safe to merge. Read through the full diff against A few things I specifically checked and didn't find problems with:
Simplify (ponytail)
DocsThe description names both docs this flow touches and either points at the amending PR or explains why it still holds: ADR-014 in 💰 Estimated review cost: $1.15 · 2m23s · 39 turns |
|
Addressed. |
a966b4a to
0746eaa
Compare
0746eaa to
677e637
Compare
677e637 to
84cb5dc
Compare
|
New commit: the subject pin accepts GitHub's immutable subject form ( |
Creating a service account now loads the owner and requires an enabled individual or organization the caller manages, so an admin can no longer create one under a nonexistent, disabled or service account — the settings layout offers the tab on a service account's own page, and nothing before this refused it. canManageServiceAccount takes the fetched owner and answers through canManageAccount, so a disabled owner takes its service accounts out of reach the way it takes the owner itself, admins aside; managedServiceAccount in lib/accounts is the one resolver every lifecycle action goes through, and each action resolves the session once instead of twice. The create form's subjects and grant fields are deduplicated before anything is written, so a workflow card or grant left in twice is one trust and one membership rather than an uncaught AlreadyTrustedError after the account exists, and a missing data-proxy endpoint fails the trust before it is written instead of handing out a step with empty endpoints. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
… parts their stories The Delete confirmation's submit sat inside AlertDialog.Action, which closes the dialog on click and unmounts the form before React dispatches the server action, so deleteServiceAccount never ran; it is a plain submit now, as DirectoryRow's is, the dialog leaves with the card once the account is gone, and stays to show the action's message when it refuses. The trust dialog keeps its form and result inside Dialog.Content, which Radix unmounts on close, so reopening it offers a fresh form rather than the previous snippet. WorkflowSnippet and GithubWorkflowFields get stories of their own, and the stories and the Storybook mock build the workflow step with githubWorkflowStep instead of carrying a copy of it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
|
Two review-fix commits, on top of the branch rather than amended into it, and #570 rebased onto them. 2ffba12 — settle a service account's owner before acting on it. Create loads the owner and requires an enabled individual or organization the caller manages, so an admin can no longer create one under a nonexistent, disabled or service account. cfc05e3 — run the delete, restart the trust dialog, and give the parts their stories. The Delete submit was inside Description updated to match; stories and screenshots added. |
…s own
The service-accounts list is one bordered list of rows, the data connections' own ConnectionList and 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. A card per account with every control on it read as a wall of badges and did not scale past a handful. Each account's page, /edit/account/{owner}/service-accounts/{id}, renders ServiceAccountDetail: the workflows it trusts as rows with Remove and the trust dialog on the section header, the products it reaches, and Disable and Delete set apart in a danger zone. Deleting redirects to the list, and the actions revalidate both pages. The id "create" is refused, since it is the create page's own path segment.
The create form drops to one line of description per section. The account id is shown as it is derived from the name, with an Edit link, the way the data-connection form shows its id. A workflow card puts the repository on its own row, with a one-line note on the immutable form, and the ref or environment on the next, chosen with a segmented control. Each product is a row with a None, Read, or Read and write control in place of a checkbox that revealed a select. The created view links to the new account's page.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
|
New commit ba495d6 redesigns the form and the list, following the data-connection pages.
Stories, screenshots and the description are updated. #570 is rebased, and its API-keys section now lives on the account's page. |
Writing a trust failed with "Attribute name is a reserved keyword; reserved keyword: identity": DynamoDB reserves `identity`, and the create condition named the sort key bare. The condition now names it through ExpressionAttributeNames. No other expression on the table names it; the reads address it through Key, which reserved words do not affect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
… link each product it reaches A service account's id is now its owner's id and its own short id joined by `--`, the delimiter account-owned data connections already use: acme--nightly-sync. 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. The form asks for the short id, derived from the name as before, and shows the whole id; the action composes it, so the id "create" no longer needs refusing. 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. Fixtures and stories use the new ids. On an account's page, each product under "Can reach" links to the product itself in a new tab, to check what it holds, with Manage beside it for the grant. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
…'s id The edit field showed "miskatonic-- nightly-upload": the slot's own padding left a gap after the prefix, and the prefix and the input were in different faces. Both are in the code face now, with no gap, so the field reads as the one id it composes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
|
New commits:
ADR-014 is amended to match on source-cooperative/data.source.coop#232. Description, stories and screenshots are updated. |
…rkflow and grants edited in place Creating a service account now redirects to its page instead of showing a post-create view, so ServiceAccountCreated is gone. On the page, each trusted GitHub workflow has an "Example usage" link that opens the step it adds in a modal, built from the page's data-proxy origin; the trust dialog closes onto the new row rather than showing the step itself, so the actions no longer build steps or read the proxy's address. "Can reach" is edited in place: each product the account reaches has a Read / Read and write control and Remove, and a form grants another of the owner's products. Three actions back it — grantProduct, setGrantRole, revokeGrant — each resolving the account through managedServiceAccount, applying serviceAccountGrantProblem, and touching only the account's own live grants; revoking sets the membership Revoked, as a person's is. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
|
New commit b1e11f1:
#570 is rebased, with its API keys section alongside. Description, stories and screenshots are updated. |
…rvice account The account's page granted products with a product dropdown, a role dropdown and a Grant button, and listed what it already had separately, with its own controls and "No products yet." when the owner had products but none were granted. It now uses the create form's list: every product the owner has, each with None / Read / Read and write, the title opening the product in a new tab. ProductAccessList is that list, shared by both; the form holds the choices until it is submitted, and the page saves each as it is made, showing it at once with useOptimistic and holding the controls while it saves. One action, setProductAccess, replaces grantProduct, setGrantRole and revokeGrant: none revokes the account's grant on the product, as a person's membership is revoked; read or write changes the grant it has or grants the product, under the same checks as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
|
New commit ce115aa makes the account's page use the create form's product list, |
The "Remove" text beside "Example usage" read as a second link and sat above it. Each trust row now ends in a red X icon button, labelled with the subject it removes and captioned "Remove" on hover; its form is a flex box, so it centres on the row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
…rom a new row The account's page listed every product the owner has with None / Read / Read and write, which is a lot of controls for editing what is already there. It now lists only the products the account reaches, each with Read / Read and write and a red X to remove it. "Grant a product" on the section header adds a row with a dropdown of the owner's other products; picking one grants it to read, saved at once like every change on the page, and the row becomes an ordinary one. The create form keeps the full list, since there every product is a choice to make. ProductAccessList takes onRemove for the page's shape (no None, an X per row), an empty message, and extra rows as children for the draft. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
|
New commit: the account page's "Can reach" lists only the products the account reaches, each with Read / Read and write and a red X. Grant a product on the section header adds a row with a dropdown of the owner's other products; picking one grants it to read, saved at once. The create form keeps the full list. #570 is rebased; description, stories and screenshots are updated. |
…n the form and the page alike Granting on the account's page chose only the product, and granted it to read the moment it was picked; the create form still listed every product with None / Read / Read and write. Both now use one ProductAccessList: the products the account reaches, each with Read / Read and write and a red X, and "Grant a product", which adds a row with the owner's other products, a Read / Read and write choice, and a Grant button that finalizes it (Cancel drops the row). The form holds its grants until "Create service account"; the page saves each when Grant, a change of access, or an X is pressed. The Grant button is highContrast, as FormActions' submit is, since a solid button in the theme's grey accent fails contrast for its label. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
|
New commit b24adc7: granting a product now picks the product and its access (Read or Read and write) in one row, finalized with a Grant button, and it is the same component on the create form and the account's page. The form holds the grants until "Create service account"; the page saves each on Grant. #570 is rebased; description, stories and screenshots are updated. |
The grant row ends in a green check, captioned and labelled "Grant", beside the X that cancels it, in place of a "Grant" text button. It stays disabled until a product is chosen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
…he grant dropdown Each option in the grant row's product dropdown now shows the product's name and, beneath it, its owner/product path in small monospace, so products with similar names can be told apart. The option grows past the one-line height Radix gives it, and the path dims the option's own colour rather than taking grey, which would vanish on the highlighted option. The closed dropdown shows only the chosen name, through the trigger's children. They are an empty string, never undefined, before a choice: Radix copies the chosen option into a trigger with no children, and switching between that copy and the children failed with "Failed to execute 'removeChild' on 'Node'". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
The card that introduces an account on hover now marks a service account with the outlined "Service account" badge the account picker and the memberships table already give it, and says which account owns it, since a service account acts for its owner. People and organizations are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
… alone "owned by @owner" beside the badge was more than the card needs; the badge says what the account is, and the card stays the short introduction it is for everyone else. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
`export * from "./service-account"` appeared twice in the types barrel. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
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
canManageAccountsays 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, orowner@id/repo@idfor 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 itsowner/productpath in small monospace), a Read / Read and write choice, and a green check that finalizes it, beside an X that cancels — the sameProductAccessListon 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 asacme--nightly-sync, joined by the--account-owned data connections already use. Two owners can each have anightly-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.ServiceAccountSchemarequires the composed form, and a membership's member id accepts it, which granting a namespaced service account a product needs. The generalcreateAccountaction no longer takestype=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' ownConnectionList/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 itsowner/productpath in small monospace), a Read / Read and write choice, and a green check that finalizes it, beside an X that cancels — the sameProductAccessListon 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 throughmanagedServiceAccountand 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, notAlertDialog.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 insideDialog.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 theaws-actions/configure-aws-credentials@v6step pointed at the data proxy —sts-endpoint,audience= the proxy origin — naming the account inrole-to-assume: arn:aws:iam::<service-account-id>:role/FullAccess, plusenvpointing 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 withGetCallerIdentity, 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 throughcanManageAccounton 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)insrc/lib/accounts/service-accounts.tsis 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. AndAccountTrustsTable.create(schema-validated, conditional,AlreadyTrustedError) anddelete, 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 grantssetProductAccess. 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 throughmanagedServiceAccount;setProductAccessalso appliesserviceAccountGrantProblem(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.removeTrustdeletes 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-productsServiceAccountList› Default, Disabled, Empty — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--defaultAccountInfoHoverCard› 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--defaultGithubWorkflowFields› 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,GithubWorkflowFieldsandProductAccessListrender there. The stories and the Storybook mock build the step withgithubWorkflowSteprather 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 list, one row per account:
The id, derived from the name under the owner, and the same id opened for editing:
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:
After Grant a product: a row to choose the product and its access, finalized with the check:
A trust row, with the Remove caption showing on the X:
The grant row's dropdown, each product's name over its path:
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:
WorkflowSnippeton its own, with a subject in the immutable form pinned to an environment — the block scrolls sideways rather than wrapping the YAML:GithubWorkflowFieldsas the create form stacks it, an immutable repository name pinned to an environment, with the Remove button:A service account's hover card:
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 whatmanagedServiceAccountdoes 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.tsalso asserts the create condition namesidentityby 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.ai/code/session_01REZWKgQy2PDETn6j9YpM4z