Skip to content

feat(cli): bgagent linear remove-workspace + DELETE route + fail-closed resolver (#306) - #681

Open
scottschreckengaust wants to merge 3 commits into
mainfrom
feat/issue-306-linear-remove-workspace
Open

feat(cli): bgagent linear remove-workspace + DELETE route + fail-closed resolver (#306)#681
scottschreckengaust wants to merge 3 commits into
mainfrom
feat/issue-306-linear-remove-workspace

Conversation

@scottschreckengaust

@scottschreckengaust scottschreckengaust commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a bgagent linear remove-workspace <slug> command that deregisters a Linear workspace, replacing the manual DynamoDB + Secrets Manager surgery removal previously required. Ships the full flow: CLI subcommand, an authenticated DELETE /v1/linear/workspaces/{slug} REST endpoint + Lambda handler, and a re-verified fail-closed OAuth resolver.

Closes #306

Root cause / contract

There was no removal path. Onboarding is one command (bgagent linear setup / add-workspace), but removal meant hand-editing the LinearWorkspaceRegistryTable row, deleting the bgagent-linear-oauth-<slug> secret, and cleaning up project mappings — error-prone, undocumented, and prone to leaving dangling secrets or stale registry rows the resolver still reads.

  • Linear subcommands live in one flat file cli/src/commands/linear.ts (siblings mirrored: add-workspace, update-webhook-secret).
  • Linear handlers are flat (cdk/src/handlers/linear-*.ts); routes are added in cdk/src/constructs/linear-integration.ts.
  • The OAuth resolver cdk/src/handlers/shared/linear-oauth-resolver.ts already fail-closes on any registry status != 'active' (re-verified under test per the AC).

The fix

CLI (cli/src/commands/linear.ts, api-client.ts, types.ts): new remove-workspace subcommand mirroring the sibling slug-validation + confirmation UX (type the slug to confirm; skipped by --yes). All destructive work is delegated to the backend via ApiClient.linearRemoveWorkspace() (DELETE), so DDB / Secrets Manager grants stay on the API role, not on every CLI user — same delegation pattern as link. Flags: --purge, --keep-mappings, --yes.

Handler (cdk/src/handlers/linear-remove-workspace.ts, new, flat): Cognito-authenticated, admin-only (caller must match the recorded installed_by_platform_user_id). Default is a SOFT removal — flip the registry row to status='revoked' (audit trail preserved) and delete the bgagent-linear-oauth-<slug> secret. --purge deletes the row outright. Secret deletion is idempotent (ResourceNotFoundException swallowed, other SM errors rethrown). Optional project-mapping cleanup keyed on linear_workspace_id.

Route + IAM (cdk/src/constructs/linear-integration.ts): DELETE /v1/linear/workspaces/{slug} behind the Cognito authorizer; new Lambda gets read/write on the registry + project-mapping tables and secretsmanager:DeleteSecret on the documented bgagent-linear-oauth-* prefix (same wildcard the webhook Lambdas already use). cdk-nag clean.

Fail-closed resolver (cdk/src/handlers/shared/linear-oauth-resolver.ts): already rejects any non-active status — so a revoked workspace stops resolving tokens and routing webhooks the instant this returns. Added an adversarial test.

Support files (sanctioned by the respective hooks): added WORKSPACE_NOT_FOUND to cdk/src/handlers/shared/response.ts; added LinearRemoveWorkspaceResponse to the CLI_ONLY_ALLOWLIST in scripts/check-types-sync.ts (it's a client-only response envelope, exactly like the existing LinearLinkResponse — CDK builds the body inline).

Docs: rewrote the "Removing a workspace" section of docs/guides/LINEAR_SETUP_GUIDE.md to lead with the command and keep manual DDB steps as a fallback (Starlight mirror regenerated via mise //docs:sync).

Testing

  • CLI (cli/test/commands/linear-remove-workspace.test.ts): --yes skips prompt + calls DELETE with default flags; --purge / --keep-mappings forward the right query params; invalid slug rejected without hitting the API; API errors surface (not swallowed); mapping-removal count reported. 6/6 pass.
  • Handler (cdk/test/handlers/linear-remove-workspace.test.ts): 401 (no JWT), 400 (bad slug), 404 (not found), 403 (not admin — no destructive calls), happy-path revoke + secret delete, --purge deletes row, secret-already-gone idempotent, mapping cleanup, --keep-mappings no-op, already-revoked → 404 (no re-revoke). 10/10 pass.
  • Resolver adversarial (cdk/test/handlers/shared/linear-oauth-resolver.test.ts): a revoked slug is REJECTED even with a fully valid non-expiring secret, and the secret is never fetched; active-control case still resolves. Plus the existing status != active → null coverage.
  • Construct (cdk/test/constructs/linear-integration.test.ts): 4 Lambdas, workspaces/{slug} resources, DELETE method is Cognito-authorized, env wiring, DeleteSecret IAM.
  • Gates: mise //cli:build (648 tests) pass, mise //cdk:build test phase (2548 tests) pass, cdk-nag clean for the new construct (verified RemoveWorkspace nag findings: []), security:sast exit 0, security:deps 0 advisories, security:sast:masking clean on my files.

Security note

Fail-closed by design: a revoked or unknown slug is REJECTED, never resolved (404 also avoids the endpoint acting as a revoke-oracle). Admin-only removal; legacy rows missing installed_by_platform_user_id can only be removed via the documented manual fallback (fail-closed default). No secrets are logged — the handler logs only slug / workspace_id / request_id / booleans. Reuses existing AWS SDK clients; no new dependencies.

Dependencies / related

Notes / follow-ups (not fixed here — out of scope)

  • Project-mapping cleanup caveat: LinearProjectMappingTable rows are keyed on linear_project_id and carry no workspace identifier in the current schema (onboard-project writes only repo / label_filter / team_id). Mapping cleanup therefore matches on a linear_workspace_id field that today's rows don't have, so it's effectively a safe no-op until that field is recorded at onboard time. Documented in the guide; a schema addition (record linear_workspace_id on mapping rows) would make cleanup fully effective — candidate follow-up issue.
  • Pre-existing ts-silent-success-masking findings on cli/src/commands/linear.ts:1765 and cli/src/linear-oauth.ts:370 are on main, outside this diff.

🤖 Generated with Claude Code


Self-review follow-up (looped /review_pr, iteration 1)

Ran pr-review-toolkit agents (code-reviewer, silent-failure-hunter, pr-test-analyzer). Blocking findings addressed in follow-up commits:

  • Partial-teardown could silently leak a credential. Because the registry row is revoked first (fail-closed) and a naive retry then 404s at the active-scan, a non-idempotent Secrets Manager delete failure would leave the OAuth secret orphaned with only an opaque 500. Fixed: that path now returns a distinct 500 SECRET_DELETE_FAILED and persists a durable secret_deletion_failed / orphaned_oauth_secret_arn marker on the registry row so the leak is discoverable. ResourceNotFoundException stays idempotent (200).
  • Observability on failure. Top-level catch now logs the failing phase + workspace; mapping cleanup logs per-page progress (request-id-reconstructable partial teardown).
  • Test coverage gaps closed: the 500/rethrow path + marker write; a test that pins the status='active' FilterExpression to the handler (not the mock); paginated mapping cleanup across LastEvaluatedKey; missing-oauth_secret_arn skip; the CLI confirmation-prompt abort/proceed branch (AC-required "prompts" surface); secret_deleted:false CLI output; and the api-client purge/keep_mappings query-string mapping.

Deferred nits (non-blocking): BatchWriteCommand for large mapping cleanups, and treating an SM InvalidRequestException "scheduled for deletion" as idempotent — candidate follow-ups, not needed for correctness here.

…ed resolver (#306)

Add a `bgagent linear remove-workspace <slug>` command that deregisters a
Linear workspace, replacing the manual DDB + Secrets Manager surgery that
removal previously required.

CLI (cli/src/commands/linear.ts): new subcommand mirroring add-workspace /
update-webhook-secret UX — slug validation + a "type the slug to confirm"
prompt (skipped by --yes). Delegates all writes to the backend via a new
DELETE call so DDB/Secrets Manager grants stay on the API role, not on every
CLI user (same pattern as `link`). Flags: --purge, --keep-mappings, --yes.

Backend: new flat handler cdk/src/handlers/linear-remove-workspace.ts behind
DELETE /v1/linear/workspaces/{slug} (route + Lambda wired in
cdk/src/constructs/linear-integration.ts). Cognito-authenticated, admin-only
(caller must match the recorded installed_by_platform_user_id). Default is a
SOFT removal: flip the registry row to status=revoked (audit trail preserved)
and delete the bgagent-linear-oauth-<slug> secret; --purge deletes the row
outright. Secret deletion is idempotent (ResourceNotFoundException swallowed,
other SM errors rethrown). Optional project-mapping cleanup keyed on
linear_workspace_id.

Fail-closed resolver: the OAuth resolver already rejects any non-active
registry status (cdk/src/handlers/shared/linear-oauth-resolver.ts) — so a
revoked workspace can no longer resolve a token or route webhooks the instant
this returns. Added an adversarial test proving a revoked slug is rejected
WITHOUT ever reading the secret, plus an active-control case.

Docs: rewrote the "Removing a workspace" section of the Linear setup guide
(Starlight mirror regenerated) to lead with the command and keep the manual
DDB steps as a fallback.

Security: no secrets logged (slug/workspace_id/booleans only); reuses existing
AWS SDK clients; no new dependencies. SAST clean on new files.

Closes #306

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
scottschreckengaust and others added 2 commits July 29, 2026 01:35
… gaps

Review follow-up (self-review agents on PR #681):

- Secret-delete failure is no longer masked: a non-idempotent SM error
  (e.g. AccessDenied) after the row is revoked now returns a distinct
  500 SECRET_DELETE_FAILED and writes a durable secret_deletion_failed /
  orphaned_oauth_secret_arn marker on the registry row, so the leaked
  credential is discoverable (a naive retry 404s at the active-scan and
  would never re-attempt the delete). ResourceNotFoundException stays
  idempotent (200).
- Top-level catch + mapping-cleanup loop now log the failing phase +
  workspace id / per-page progress so on-call can locate an orphaned
  secret or half-cleaned mapping table from the request id.
- Tests: SECRET_DELETE_FAILED path + marker write; FilterExpression
  pins the status='active' fail-closed filter to the handler (not the
  mock); paginated mapping cleanup across LastEvaluatedKey; missing
  oauth_secret_arn skip; CLI confirmation-prompt abort/proceed;
  secret_deleted:false CLI output; api-client query-string mapping
  (purge / keep_mappings snake_case).

Relates to #306

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Comment-only: the 30s timeout is higher than the 3s default the
link/webhook handlers use, not "higher than the other request handlers"
(the webhook processor also uses 30s). Nit from PR #681 self-review.

Relates to #306

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@scottschreckengaust
scottschreckengaust marked this pull request as ready for review July 29, 2026 03:03
@scottschreckengaust
scottschreckengaust requested review from a team as code owners July 29, 2026 03:03
@scottschreckengaust

Copy link
Copy Markdown
Contributor Author

✅ Acceptance summary (for the reviewer)

#306bgagent linear remove-workspace <slug>.

  • New admin-only CLI command → new authenticated DELETE /v1/linear/workspaces/{slug} handler (cdk/src/handlers/linear-remove-workspace.ts) + route/IAM in linear-integration.ts.
  • Default soft-revoke (status=revoked, audit-preserving); --purge deletes the row. Deletes the per-workspace OAuth secret (idempotent on ResourceNotFound; distinct SECRET_DELETE_FAILED + durable orphan marker on other SM errors). Optional project-mapping cleanup.
  • Fail-closed re-verified by adversarial test: the resolver rejects a revoked slug without ever reading the secret. TDD throughout (handler 14 + resolver + construct + CLI 8 + api-client 3 + prompt 2).

/review_pr = approve-with-nits, zero blocking. Known limitation documented: project-mapping cleanup is a safe no-op until linear_workspace_id is recorded on mapping rows at onboard time (candidate follow-up).

🔀 Merge guidance (for the reviewer)

Cluster ua-broad — must-follow #345. Shares cli/src/commands/linear.ts, cdk/src/constructs/linear-integration.ts, and cdk/src/handlers/shared/linear-oauth-resolver.ts with #345 (the UA aspect). Edits are localized, so rebase after #345 merges is mechanical. Not strict-up-to-date-gated. Native auto-merge disabled repo-wide; awaits your manual squash-merge.

🤖 orchestrator note (agent) — promotion is orchestrator-driven; merge remains a human action.

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.

feat(cli): bgagent linear remove-workspace command

1 participant