Skip to content

feat: service-account commands for service accounts, their sign-ins and product access - #58

Open
alukach wants to merge 5 commits into
mainfrom
feat/service-accounts
Open

alukach wants to merge 5 commits into
mainfrom
feat/service-accounts

Conversation

@alukach

@alukach alukach commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Adds source-coop service-account, built against the draft service accounts API on source.coop's feat/api-service-accounts (OpenAPI). Its layout and wording follow the web UI's service account components (src/components/features/service-accounts/*): who it is, signs in with, can reach.

Demos

Recorded against demo/stub.py's made-up records (#59), hence the localhost URLs. The GitHub subject check is real: it asks api.github.com about source-cooperative/source.coop. Each plays inline; click one for asciinema's player, where you can pause and copy text. To re-record them once #59 is in:

cargo build && python3 -I demo/record.py demo/demos/service-accounts.py --upload \
    --gif-base https://github.com/source-cooperative/source-coop-cli/raw/demo-assets/pr-58

service-account list: an owner's service accounts, then as --json

list

service-account view: who it is, signs in with, can reach; --json adds trusts and memberships

view

service-account create with prompts: owner, name, ID, a GitHub workflow (checked with GitHub), an API key, a product grant, review, then saving the key

create

service-account create from flags, --json

create-flags

service-account list -i: open a record, go back, delete one from its screen

picker

service-account edit: --disable and --enable (with the web UI's explanations), then a rename in the edit form

edit

service-account delete: typed confirmation, then --yes --json

delete

service-account sign-in add with prompts (a workflow) and from flags, sign-in remove -i, sign-in revoke-key

sign-in

service-account view with API keys, sign-in add --key --out, sign-in edit -i (a new expiry) and sign-in remove key:HINT (revoke, typed back)

keys

service-account edit --access, then the edit form: take a product away, grant another, review and save

access

service-account usage: pick a sign-in for its workflow, then --key

usage

Commands

source-coop service-account list my-org [-i] [--json] [--no-pager]
source-coop service-account view my-org--nightly-sync [-i] [--json]        # --json adds `trusts`, `keys` and `memberships`
source-coop service-account create my-org--nightly-sync --name "Nightly sync" \
    --github my-org/data --branch main --key "HPC cron job" --out hpc.key \
    --access my-org/my-data=write                                # or no flags: prompts
source-coop service-account edit my-org--nightly-sync [--name …] [--access my-org/my-data=read|write|none …] [--disable | --enable]   # no flags: a form
source-coop service-account delete my-org--nightly-sync [--yes]

source-coop service-account sign-in add my-org--nightly-sync --github my-org/data --environment production
source-coop service-account sign-in add my-org--nightly-sync --subject repo:my-org/data:ref:refs/tags/v1
source-coop service-account sign-in add my-org--nightly-sync --key "HPC cron job" [--expires 30d|90d|1y|never|DATE] [--out FILE]
source-coop service-account sign-in edit my-org--nightly-sync [key:HINT | -i] [--expires …]
source-coop service-account sign-in remove my-org--nightly-sync [SUBJECT | key:HINT | -i] [--yes]
source-coop service-account sign-in revoke-key < leaked-key-file           # no sign-in needed

source-coop service-account usage my-org--nightly-sync --subject … > .github/workflows/source-coop.yml
source-coop service-account usage my-org--nightly-sync --key

How it matches the web UI

  • GitHub workflows: --github OWNER/REPO with --branch, --tag or --environment builds the token subject, as GithubWorkflowFields does.
    • Like the web UI, the CLI asks GitHub how the repository's tokens name it, which may be by ID (owner@1/repo@2). It warns about custom subject templates and about repositories it can't see.
    • Unlike the web UI, it sends GH_TOKEN/GITHUB_TOKEN when set, so private repositories can be looked up too.
  • Review before trusting: the review step shows the "Trusts tokens matching" condition, with the proxy as the token's audience.
  • usage: a line-for-line port of service-account-usage.ts. The tests check it against the web UI's own tests.
  • Workloads use stock AWS tools: they sign in through aws-actions/configure-aws-credentials, or AWS_WEB_IDENTITY_TOKEN_FILE for a key. creds is unchanged.
  • Prompted create: it follows the form's order (owner, name, ID made from the name, workflows, products, review), with ↑/← back, drafts, a sign-in check before the first question, and re-asking only the fields the API rejects.
  • API keys: they follow IssueApiKeyDialog, ApiKeyList and IssuedApiKey, using the draft API's /accounts/{id}/keys (source.coop#665).
    • A key has a label ("where this key lives") and an expiry: 30 days, 90 days (the default), a year, never, or a date.
    • It's shown once. --out writes it to a 0600 file. On a terminal, you're offered a file to save it to; piped, it goes alone to stdout.
    • view lists keys by label and hint (key:a1B2c3), with when each was last used and when it ends. Revoked and expired keys are dimmed, and the list says when GitHub's secret scanning revoked one.
    • Revoking asks you to type key:HINT back, since a revoked key can't be restored.
    • The prompted create offers one "API key…", as the web form does.
  • Edit, disable/enable and delete:
    • With no flags, edit opens a form like feat(product): edit opens a form of current values instead of asking which fields to change #62's product edit, laid out in a label column as view is: the Name field, then Can reach over each product the account reaches with its role (Read, Read and write, or No access), Grant a product…, then a review before saving. Changed rows show what they were.
    • --access ACCOUNT/PRODUCT=read|write|none does the same from the command line, in the shape create --access takes. A role a product already has, or taking away access it doesn't have, changes nothing, so a draft sent twice does no harm.
    • edit --json prints the account as view --json does, memberships included.
    • --disable and --enable are flags only, as feat(product): edit opens a form of current values instead of asking which fields to change #62 left them for products, and they print the web UI's explanation of what they do.
    • delete asks you to type the ID back.
  • Shell completion (feat: shell completion for commands, flags and ACCOUNT/PRODUCT names #63): TAB completes whole service account IDs (my-org--nightly-sync) from the accounts whose service accounts you can list, and owners for list. A new ID for create isn't completed, because bash and zsh would put a space after my-org--.

Not in this PR

These are blocked on the draft API:

  • view finds the account by scanning its owner's listing. GET /accounts/{id} answers 404 for a service account unless you're an admin. There's a ponytail: comment to switch to a direct fetch once the account's managers may read it.
  • list shows no counts of workflows, keys and products per account, as the web UI does. That would take three extra calls per row unless ServiceAccountPage carries them.
  • Adding a trust is GitHub-only. POST /trusts takes a GitHub-only body, so other identity providers would need API changes. sign-in add --issuer is left out until the API takes one.

These are left for later:

  • The -i record screen has b/e/d only. Adding or removing sign-ins and access from it is a follow-up.

Also changed

  • The convention test skips subcommand groups (resource_commands_follow_the_cli_conventions). Without that, it would require -i on sign-in itself; their leaf commands are still checked.
  • Built on main's shared Site (--api-url / --proxy-url) and the helpers feat: manage members and invitations (member, invitation) #57 made pub(crate) in product.rs, so this PR doesn't change product.rs.

Test plan

  • cargo fmt, cargo clippy --all-targets -- -D warnings
  • cargo test and cargo test --features staging: 157 pass. The 17 new tests cover edit --access granting, changing and taking away access once each; the edit form renaming, taking a product away and granting another after a review (the whole asked list); --expires parsing, including counts too big for a date; a key that can't be saved being shown, or kept in the JSON, rather than lost; completing whole service account IDs from the owners you can list; issuing a key to a 0600 file, with --json naming key_file and leaving out the key; one sign-in at a time; finding keys by hint, giving one a new expiry and revoking it (exit 5 for an unknown hint); a prompted revoke that asks for the typed hint; subject building and labels, usage snippets matching the web UI, create from flags and fully prompted with a workflow, a key and a grant (the whole asked list), view composing trusts, keys (live and revoked) and memberships (and exiting 5 when the account isn't found), sign-in remove's query and --yes, and delete --yes.
  • Manual runs with a fake SOURCE_TOKEN and a temp HOME: usage --key and --subject, bad-ID usage error (exit 2), delete refusing without --yes, --json errors.
  • Not yet run against a live deployment of the draft API.

🤖 Generated with Claude Code

@claude

claude Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Claude finished @alukach's task in 16s —— View job


✅ No blocking issues — safe to merge.

I read the latest commit (3cc06af) and the cache.rs and main.rs changes. I did not re-read all 3.5k lines of service_account.rs, and I did not run cargo test because the sandbox needed approval for it. Nothing I read is a correctness or security problem.

  • Key delivery: deliver_key (src/service_account.rs:718) shows the key whenever saving fails or the prompt is cancelled. It re-raises Ctrl+C only after the key has been shown, so a new key can't be lost.
  • File safety: write_new_file (src/cache.rs:369) uses create_new with mode 0600 and creates no directories. A failed write after the file is created can leave an empty or partial file at the path, but the key is still shown on screen. This is harmless.
  • Edit access: only JSON null revokes access. Any other non-role value is an error, and memberships are fetched once per edit.
  • GitHub lookup: it has a 10s timeout, and the token goes only to api.github.com.
  • Back navigation in create: the before snapshot stops draft-filled fields from being counted as already given.

Simplify (ponytail)

  • src/service_account.rs (whole file, 3.5k lines): it is one module, so splitting it is a follow-up refactor, as you already noted.
  • src/service_account.rs:738: Some(e).filter(|e| e == INTERRUPTED) can be (e == INTERRUPTED).then_some(e).
  • src/service_account.rs:~1940: the role parsing tries Role::from_api and then Role::from_str(.., true). Accept the API forms only, unless --from-file really needs read and write as well.

💰 Estimated review cost: $0.17 · 0m16s · 7 turns

alukach added a commit that referenced this pull request Oct 17, 2026
alukach added a commit that referenced this pull request Oct 17, 2026
alukach added a commit that referenced this pull request Oct 19, 2026
… edit and completion

From the review on #58:
- A key that can't be written to --out (or the path typed at the prompt)
  is shown instead, and under --json stays in the record, rather than lost
  after the API has issued it
- --expires with a count too big for a date is a usage error, not a panic
- expiry_label reads its date with time(), as the rest do

Rebased onto main:
- edit with no flags asks for the name from the current one, and
  --disable / --enable are flags only, as product edit's form (#62) leaves
  them; their effect is explained after the change
- TAB completes service account IDs, owners and products, as #63's
  convention test requires; a new ID for create isn't completed, since
  shells would put a space after `OWNER--`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach
alukach force-pushed the feat/service-accounts branch from c4474a3 to f8d455c Compare October 19, 2026 19:45
alukach added a commit that referenced this pull request Oct 19, 2026
@alukach

alukach commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in f8d455c, after rebasing onto main:

  • A freshly issued key could be lost if saving it failed. Fixed. If writing to --out, or to the path typed at the prompt, fails, the key is shown instead. Under --json, it stays in the record (key, with no key_file). Tested with a directory as the path.
  • --expires 99999999999d panicked. Fixed. Days and years use checked arithmetic (checked_mul, Duration::try_days, checked_add_signed), so a count too big for a date gets the usual "expected 30d, 90d…" usage error. Tested for both units.
  • time() duplicated expiry_label's parsing. Fixed: expiry_label calls time().
  • Not taken:
    • Inline Context and subject(): they're used by the prompted GitHub flow (ask_github) as well as by github_subject, so inlining them would duplicate them.
    • A KeyState enum, and the expect in ask_expiry: left as they are. The review called the first advisory, and the second re-parses four constants that a test covers.

The rebase also needed two changes beyond the conflicts:

🤖 Generated with Claude Code

…nd product access

`source-coop service-account` lists, views, creates, edits and deletes
service accounts, laid out as the web UI's page is: who it is, how it
signs in, what it can reach.

- `sign-in add/edit/remove` trusts GitHub Actions workflows (subjects
  built from --github and --branch/--tag/--environment, checked with
  GitHub as the web UI does) and issues, re-expires and revokes API keys,
  each shown once and never lost when saving it fails
- `sign-in revoke-key` revokes a leaked API key read from stdin
- `access set/remove` grants or ends product memberships
- `usage` prints the web UI's workflow and API-key snippets
- `create` prompts in the web form's order, with back, review and drafts
- TAB completes service account IDs, owners and products

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach
alukach force-pushed the feat/service-accounts branch from f8d455c to a39a6c5 Compare October 19, 2026 20:39
@alukach
alukach marked this pull request as ready for review October 19, 2026 20:52
…rate access command

From review on #58:
- `edit --access ACCOUNT/PRODUCT=read|write|none` (repeatable) grants a
  product, changes its role or takes it away, the same shape `create
  --access` takes; already-so changes nothing, so a draft sent twice is safe
- `edit` with no flags opens a form like `product edit`'s: the name and
  each product reached, "Grant a product…", then a review before saving
- `edit --json` prints the account as `view --json` does, memberships too
- the `access set` / `access remove` commands are gone (never released)
- the README says how to list without the pager: --no-pager, SOURCE_PAGER=cat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit that referenced this pull request Oct 19, 2026
…w` does

"Name" heads the field and "Can reach" the grants, each a product, its
role, then what it was, with "+ Grant a product…" among them; the review
before saving uses the same columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit that referenced this pull request Oct 19, 2026
From the second review on #58:
- --out and the "Save the key to a file" prompt only create a new file
  (`cache::write_new_file`: create_new, 0600, no directories made), so a
  mistyped path can't destroy one; the key is shown instead, as for any
  failed save
- GH_TOKEN / GITHUB_TOKEN is only sent to api.github.com
- a date given to --expires is at midnight UTC, as help and README now say
- expiry_label parses its date directly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach

alukach commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in d956c47:

  • --out silently overwrote an existing file. Fixed. --out and the "Save the key to a file" prompt now write through cache::write_new_file, which uses create_new, mode 0600, and makes no directories.
    • An existing file is left untouched, and the key is shown instead, as for any failed save.
    • Tested with --out pointed at a file that's already there, and by hand against the demo stub.
  • The GitHub token should go only to api.github.com. Done. The token is attached only when github_api is GITHUB_API.
  • A date-only expiry is midnight UTC. Now stated in --help and the README.
  • expiry_label built JSON to call time(). Fixed: it now parses the date directly.
  • Not taken:
    • Pre-parsing the EXPIRIES constants: left as before, since a test covers them.
    • Keeping Context and subject(): agreed, kept.
    • Splitting the 3.5k-line file: better as its own refactor PR than mixed into this one.

🤖 Generated with Claude Code

- Always show a new key when the save prompt is cancelled
- Only null revokes access in edit; accept read/write, reject unknown values
- Keep earlier answers reachable when going back past the ID in create
- Honour local_id from --from-file in create
- Exit on Ctrl+C in the edit form's grant flow
- Time out the GitHub repository lookup after 10s
- Check sign-in before the first prompt in sign-in remove -i
- Fetch memberships once per edit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant