Bulk delete, move, change-org credentials

Task 9405 · Done · Coder · created · updated

Part of epic Task 9387 "Operator credentials roster UX fixes at scale". PRD = Task 9385 and architecture = Task 9386, both settled. IDs AC-x.y and D0–D9 are the PRD's; A1–A22 are the architecture's. Everything this child needs is restated here. Base: `develop` already contains Task 9378; never re-implement it. Original work item (verbatim): > Follow-up to Task 9378 (operator credentials page UX). That task shipped server-side sort (Created/Name/Type/Org), a "1–25 of N" count, and shows the tenant once per row. These items remain for an operator working across all tenants' credentials, up to millions of rows: > 1. **Secrets filter scales.** `?secrets=` decrypts every row in scope on each request (`apps/api/app/routes/credentials/reads.py::_secrets_filter`) and puts every id into an `IN` list (Postgres allows at most 65535). Store a "has stored secret" boolean when the credential is written, so the filter becomes an indexed predicate. > 2. **Paging without full counts.** Replace exact `COUNT(*)` + OFFSET with keyset (cursor) paging and a capped/estimated total ("10,000+"). Add indexes behind each sort order (`lower(name)`, `type`, org name). > 3. **Search that finds things at scale.** Add a pg_trgm index on name/description. For operators, also match org name/slug and owner email. > 4. **Owner picker as an async typeahead.** A native select cannot hold thousands of owners across tenants. Make it multi-select. > 5. **Tree mode beyond 200 rows.** Build tree mode from a server-side folder listing (folders with counts, children loaded on open) instead of one 200-row page. > 6. **Bulk actions.** Let operators select rows (per page, plus "all N matching") and then delete, change org or move them to a folder. > 7. **Health columns and filters.** Add last used, last test result, and expired/expiring/never used, each also available as a filter. > > Out of scope: the three items Task 9378 shipped; credential editor pages; runner credential resolution. > > Done means: > - Each item above is shipped or explicitly declined with a reason. > - With 1M credentials in a dev DB, the roster's first page with any filter or sort loads in under 1s (EXPLAIN shows an index scan). > - An operator can bulk-delete 500 matching credentials in one action. Shared rules: - D1: "operator" means a principal on the operator plane holding cross-tenant `credentials:view`. [op] marks behavior that applies to the operator only. - No scope widens. The bulk target set is built from `roster.py` (`roster_where`), so a bulk action can never reach a row the caller cannot see. - No secret or config value appears in any response, log or audit text. - UI copy follows `docs/standards/ui-copy-standards.md`; layout follows `docs/standards/ui-style-guide.md`. ## This child (architecture C6): bulk actions Covers item 6. It needs `roster.py` and `RosterFilterSet` from the "Keyset roster query builder and API" child. Approach (settled decisions): - **A16 — endpoints.** `POST /api/credentials/bulk/delete`, `/bulk/move-folder`, `/bulk/change-org`. - Body: `{"ids"?: uuid[≤10000], "match"?: RosterFilterSet, "expect_count": int, "to"?: str (folder prefix), "org_id"?: uuid}`. Exactly one of `ids` or `match`. - `match` resolves through the roster builder (same scope and filters) with `LIMIT 10001`. - Over 10,000 → 422 `BULK_CAP`. - A resolved count ≠ `expect_count` → 409 `set_changed`: "The matching set changed — review and retry". - All-or-nothing: `SELECT … FOR UPDATE` on the set, run every refusal set-based, then write. - Success: 200 → `{"count": n}`. - A refusal names `blocked: int` and a `reason`: - `forbidden` 403 - `managed` 409 - `name_collision` 409, naming the colliding name - `set_changed` 409 - `BULK_CAP` 422 - change-org only: `consumer`, `nodevault`, `system`, `project_home`, `non_member_owner`, each 409 - **A17 — delete.** - One set-based `refuse_managed_credentials(session, ids)`: the set version of `nodelinks.refuse_managed_credential`, one query. - A permission check via `credential_write_clause(principal, DELETE)`; every row must pass. - A bulk `CredentialIdTombstone` insert. - One `_audit` event per row, as a batched insert. - Then `DELETE … WHERE id = ANY(:ids)`. - The single-row `delete_credential` (`writes.py`) is refactored to call the same set function with one id. - **A18 — move-folder.** Generalise `move_credential_folder` (`writes.py`) to an arbitrary id set: compute the new prefix + current leaf for each row, then make one `_refuse_duplicates_in_home` call (`credential_wire.py`, already set-based with per-home advisory locks). The existing endpoint becomes a caller. - **A19 — change-org, [op] only** (`require_superadmin` on the operator plane). This deviates from D5 by refusing more. - Refused, as D5 says: `system` rows; project-homed rows; personal rows whose owner is not a member of the target org. - Also refused: `nodevault` rows, whose envelopes are sealed under the source org's key; and any row with a consumer (`credential_consumers.consumers_of`, every composite `(credential_id, org_id)` FK child included). The refusal names each blocking consumer by its label. - Writes `org_id` through the ORM, so the tenancy listeners see the change. - Runs `_refuse_duplicates_in_home` for the target org. - Writes one audit event per row, naming the old and the new org. - The envelope AAD is id + type, not the org, so tier 1 and tier 2 stay openable after the move. Re-verify this in review. - **A20 — UI.** - Flat mode gets a checkbox per row and a header checkbox for the current page. After a full-page select, a "Select all N matching" banner appears (N shows "10,000+" when capped). Follow `apps/web/src/pages/Tasks.tsx` (~:1655-1688) and `apps/web/src/ui/BulkActionBar.tsx`. - "All matching" sends `match` + `expect_count`, never ids. - Selection clears on any filter or sort change. - Each action is ONE server call. Do NOT use `useBulkRuns`, which fans out one call per row. - Actions: Delete; Change org [op]; Move to folder. - Each confirmation names the exact count and verb. Delete says it cannot be undone. - Over the cap, the action is disabled with the cap message. - On success, show a toast ("Deleted N" / "Moved N") and refresh the roster. - **Writer census.** Add the bulk delete/move/change-org writers to the foundation child's census test (AC-1.2 covers "folder move, org change"). - **Explain case and docs.** Add the bulk-resolve case to `tools/perf/credentials_roster_explain.py`, and a bulk section to `apps/docs/content/console-reference/directory/credentials.md`. - **Security surface.** Change-org moves secrets across tenants, and delete is destructive. - The autonomous review lane MUST include `security-reviewer`, re-checking R4: no reference may be left behind that leaks a secret to the wrong tenant. - Merge per the project rules (Full Auto; no human approval needed). Credentials & reach (architecture §9, verbatim; "C1's script" = the foundation child's seed script): - "Unit/API tests for every AC | Nothing beyond the worktree (`./dc test`). | verified — same." - "AC-P (1M rows, EXPLAIN < 1 s) | Nothing beyond the worktree: the slot's local compose DB (`./dc psql`), seeded by C1's script. | verified — needs only the worktree; the stack is started by the coder (`./dc up`)." Out of scope: - MUST NOT re-implement or change Task 9378's sort control, "1–25 of N" count format (other than the capped variant) or once-per-row tenant display. - MUST NOT change credential editor pages: `apps/web/src/pages/credentials/CredentialEditPage.tsx`, `AccountEditPage.tsx`, `credentialForm.tsx`, nor the Accounts page (`Accounts.tsx`). - MUST NOT change runner credential resolution: what `routes/agent/task_secrets.py` and the credential-get path return, who may call them, or path resolution (`runner_path`). Only the additive best-effort `last_used_at` stamp (D6) is allowed. - MUST NOT change the sealing scheme (`app/credential_crypto.py`) or put config into SQL predicates. - MUST NOT make health columns sortable, add "jump to page N", or add bulk verbs beyond delete / change org / move to folder. - MUST NOT change `CredentialSelect` or other `page`-based pickers' behavior. - Do NOT touch the paging/search/health UI, the owner picker or the tree (sibling children). - Do NOT change the observable behavior of the single-row endpoints; only their internals move onto the shared set functions. Done means (PRD criteria verbatim with their oracles; deviations marked): - **6.1** Flat mode has a checkbox per row, a header checkbox selecting the current page, and after a full-page select a banner "Select all N matching" (N capped "10,000+" per D2/D4). Selection clears when filters/sort change. → verify: web test for each selection state (none, some, page, all matching). It reuses `BulkActionBar`; `useBulkRuns` is deliberately not used (A20). - **6.2** Actions: **Delete**, **Change org [op]**, **Move to folder**. Each opens a confirmation naming the exact count and action; Delete says it cannot be undone. → verify: web test. - **6.3** Server endpoints accept either an explicit id list or the current filter set (the same scope as the roster, so a bulk action can never reach a row the caller cannot see). All-or-nothing per D4; each affected row writes the same audit event its single-row action writes today (`writes.py` `_audit`); delete writes the same `CredentialIdTombstone` and runs the managed-credential refusal per row — a managed row blocks the batch with a message naming it. → verify, by API tests: - mixed-permission batch → 403, nothing changed; - managed row → refused, nothing changed; - success → audit rows = N; - `expect_count` mismatch → 409, nothing changed. - **6.4** An operator bulk-deletes 500 rows matching a filter in one action, completing in under 10 s, roster refreshing to show them gone. → verify: an API test with 500 rows via `match` asserts it finishes under 10 s with an O(1) query count (not 500 × N). The dev browser walk is the acceptance child's job. - **6.5** Move to folder: per-home name rules hold (`_refuse_duplicates_in_home`); a collision refuses the whole batch naming the colliding name. → verify: API test. - **6.6 [op]** Change org: per D5 plus A19 (system, project-home, non-member personal owner, NodeVault and consumer rows refused, naming each consumer); tenant callers never see the action and the endpoint 403s them. → verify: API tests per caller and per home kind, plus one asserting that a moved non-NodeVault row's config still opens. - **6.7** Over 10,000 rows targeted → 422 naming the cap; UI disables the action with that message. → verify: API test with cap lowered via test hook; web test. - **6.8** Tenant admins get Delete and Move to folder within their scope (same all-or-nothing rules); keyless members on `mine` get them for their own rows. → verify: API test per caller. - The PR records the `security-reviewer` verdict. - The explain script's bulk-resolve case runs in under 1000 ms on the 1M seed. - `./dc check quick` is green.

Outcome

Shipped bulk Delete / Move to folder / Change org [op] for credentials, each one all-or-nothing call (ids or match, cap 10k, expect_count). Flat roster has ticks, Select all N matching, and a toast. Quick gate green. Review lanes passed (security, code, verifier). PR #6754 queued on the merge lane. Visual check done on desktop and phone. Explain bulk-resolve on 1M rows: slowest 85 ms. Deferred: one advisory lock per name on capped sets. Memory: none applied.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)