Skip to content

Shred retired secrets and gate spending a member's token - #2115

Open
ppXD wants to merge 1 commit into
fix/redact-agent-transcripts-and-grade-evidencefrom
fix/shred-retired-secrets-and-gate-personal-tokens
Open

ppXD wants to merge 1 commit into
fix/redact-agent-transcripts-and-grade-evidencefrom
fix/shred-retired-secrets-and-gate-personal-tokens

Conversation

@ppXD

@ppXD ppXD commented Oct 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • Deleting a git provider connection leaves no secret decryptable and no grant or hook live.
    • ProviderInstanceService.DeleteAsync (backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceService.cs:241) first retires the connection's group/organization hooks (ProviderInstanceService.Retirement.cs:44).
    • Each hook is deleted at GitLab/GitHub with the token that registered it, best effort. This happens while that token still works, because once the delete blanks it nothing could remove a hook that keeps delivering the group's events. Registered rows are dropped, the rest Cancelled, and every hook row the connection keeps has its secret_enc blanked.
    • Each credential is then retired through ICredentialRevocationService.RetireAsync: token blanked, Revoked, soft-deleted, act-as identities removed. oauth_client_secret_enc is nulled.
    • After commit, each OAuth grant is revoked at the provider (refresh token, then access token, RFC 7009), best effort within a 30 s budget, so a rolled-back delete never ends a grant. The revoke that Disconnect already did moves into the shared IOAuthGrantRevoker.
  • Retired secrets are crypto-shredded (backend/src/CodeSpace.Core/Persistence/DbUpFiles/0249_shred_retired_secrets.sql).
    • Variables: a deleted Secret variable holds no ciphertext. chk_variable_value_exclusive is relaxed for deleted rows only.
    • Storage credential revisions: a revision may be blanked exactly once, and only when its credential is Revoked. A superseded revision of an Active credential is kept, because a storage profile pins an exact revision and routed bytes still decrypt through it.
    • Deployment storage defaults: an envelope may be blanked exactly once, and only while no template names it. StorageDefaultService.UpdateAsync (StorageDefaultService.cs:83) blanks the envelope that a rotation or a clear replaced, in the same transaction. The materializer decrypts only the envelope its template names, read under a share lock.
    • Both guards still refuse every other UPDATE and DELETE, and refuse blank inserts.
    • The script backfills what retirement already left behind:
      • Revoked or soft-deleted credential tokens.
      • Deleted connections' client secrets.
      • Deleted connections' hooks: set to Cancelled with the secret blanked. external_id/owner_path are kept so an operator can remove a hook still at the provider by hand; deliveries to it are refused as retired.
      • Deleted Secret variables.
      • Revisions of revoked storage credentials.
      • Default envelopes no template names.
    • The rollback note can now be run as written. The old CHECKs reject shredded rows (23514), so the header gives exact NOT VALID statements and says to re-run 0126's and 0173's guard functions verbatim. A test runs those statements on a shredded database.
  • Listing what a credential can reach now requires its owner or repos.manage. ListAccessibleRepositoriesQuery implements IRequireCredentialSpend.
    • A Personal credential lists only for its owner or a repos.manage holder.
    • A team-service credential lists only for a repos.manage holder.
    • Anyone else gets 403 before the token reaches the provider.
    • The rule lives in CredentialOwnerOrPermission, shared with IRequireCredentialManagement. Instance Admins keep their Owner standing.

No API shape changes. Behaviour changes, all security-required:

  • A Viewer or Member asking for another member's personal credential's accessible repositories now gets 403.
  • Deleting a provider instance now calls the provider's hook-delete and token-revoke endpoints.
  • Rotating or clearing a deployment storage default's secret makes the replaced secret unrecoverable.

Test plan

  • Unit: model payload checks match 0249 (theory over revision and default envelope); spend-gate wiring; OAuthGrantRevoker; variable retirement. Full unit suite: 13660 passed.
  • Integration:
    • Provider-instance delete retires credentials and the client secret, revokes grants after commit, and takes group hooks down with a live token (provider accepting or refusing).
    • Storage default envelope guard: one blanking, only while unnamed, no refill, no blank insert.
    • Rotate then clear a template's secret, and materialize a team after the rotation.
    • Variable shred; storage credential revision shred.
  • Migration on a populated DB (RetiredSecretShredMigrationTests): a pre-0249 database upgraded through the production DbUpRunner. Backfills, guards after the upgrade, and the documented rollback run on a shredded database.
  • E2E (HTTP, GitLab stub on a loopback port):
    • Delete revokes OAuth grants at GitLab and leaves no token or client secret.
    • Delete with the provider down still retires everything.
    • Connection-wide delete sends the group-hook DELETE with the registering PAT.
    • Viewer or Member against another member's PAT gets 403; the owner and a repos.manage holder get 200.
    • Deleted-variable shred.
    • Storage default rotate and clear over /api/admin/storage-defaults.
  • Regression: integration namespaces ProviderInstances, Webhooks, Persistence, Storage, Credentials, Variables, Binding, OAuth, Providers, Agents, Sessions and others; E2E Repositories, Storage, Credentials, Webhooks, Auth.
  • Staging: after deploy, check provider-instance deletes for "Failed to delete remote connection webhook" warnings. Each one is a group hook to remove by hand at the provider.

Deleting a git provider connection marked its credentials Revoked but
kept every token and the connection's OAuth client secret decryptable
in the database, and never told the provider, so an OAuth refresh
token stayed valid at GitHub/GitLab while the UI said "Revoked". The
delete now retires each credential through the same path a departure
or an unlink uses (token blanked, Revoked, soft-deleted, act-as
identities gone), nulls the client secret, and revokes each OAuth
grant at the provider after the transaction commits, best effort and
bounded, so a rolled-back delete never ends a grant. The refresh-then-
access revoke a Disconnect already did moves into IOAuthGrantRevoker,
shared by both paths.

A connection-wide connection's group hooks outlived the delete too:
still registered at GitLab/GitHub, still delivering, their HMAC
secrets decryptable, and their registering token gone, so nothing
could ever remove them. The delete now retires them first, while that
token still works, the way a switch back to per-repository scope does,
and blanks the secret of every hook row the connection keeps.

Deleted variables, revoked storage credentials and rotated or cleared
deployment storage defaults kept their ciphertext forever, because
0012's check demanded a ciphertext on every Secret row and the 0126
and 0173 guards refused every UPDATE. Migration 0249 lets a deleted
Secret hold none, lets a revision be blanked exactly once and only
when its credential is Revoked, lets a default's envelope be blanked
exactly once and only when no template names it, and blanks what
retirement already left behind. A superseded revision of an Active
credential is kept: storage profiles pin an exact revision and the
resolver still decrypts it for bytes stored through it. The
materializer decrypts only the envelope its template names, so the
one an edit replaces is blanked in the same transaction. The old
checks reject shredded rows, so 0249's rollback note re-adds them NOT
VALID; a test runs that note on a shredded database.

Listing a credential's accessible repositories presents the token to
the provider, yet needed only team membership, so a Viewer could spend
the Owner's personal token to list private repositories outside the
team. The query now takes IRequireCredentialSpend: the owner of a
Personal credential, or a repos.manage holder; the rule is shared with
IRequireCredentialManagement.
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