Repository navigation
Conversation
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
credential's tokens at the provider after the transaction commits,
best effort and bounded, so a rolled-back delete never ends a token.
The refresh-then-access revoke a Disconnect already did moves into
IOAuthGrantRevoker, shared by both paths.
The delete revokes those tokens only, never the person's whole grant.
Two teams may connect through the same OAuth App, and GitHub keeps one
grant per user and app: DELETE /applications/{client_id}/grant would
end the person's token in every other team too. OAuthRevokeInput gains
WholeGrant; a Disconnect keeps the grant delete it always sent, while
the connection delete asks GitHub's DELETE .../token for the access
token and sends no refresh token, which GitHub cannot end alone.
GitLab revokes per token (RFC 7009) either way.
A refresh in flight when a retirement commits finds its row blanked
and revokes the pair the provider just issued, which nothing stores.
It sent that pair as a grant delete, so a departure or a relink
racing a GitHub refresh, and now a connection delete or the sweep
below, ended the person's tokens in every other team. The refresher
now ends the pair the way the write that blanked the row ends tokens:
a Disconnect leaves the row listed and still ends the grant; a
retirement soft-deletes it, and only the pair is ended.
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, and the note now
says how to roll forward again afterwards.
0249 runs once, at the first new pod's start, but the previous build
keeps retiring secrets without blanking them wherever it still runs:
beside the new one during a rolling deploy, or alone after a rollback.
An hourly recurring sweep (job, command, handler, service) blanks
exactly the rows 0249's backfill blanks, so those are shredded on the
next tick; a test holds the sweep to the migration's own assertions.
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.
ppXD
force-pushed
the
fix/redact-agent-transcripts-and-grade-evidence
branch
from
October 13, 2026 07:38
f99c9ff to
1e610fe
Compare
ppXD
force-pushed
the
fix/shred-retired-secrets-and-gate-personal-tokens
branch
from
October 13, 2026 07:39
1c0f600 to
86cf4b8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ProviderInstanceService.DeleteAsync(backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceService.cs:235) first retires the connection's group and organization hooks (ProviderInstanceService.Retirement.cs:46).secret_encblanked.ICredentialRevocationService.RetireAsync: token blanked, status Revoked, row soft-deleted, act-as identities removed.oauth_client_secret_encis nulled.IOAuthGrantRevoker.DELETE /applications/{client_id}/grantwould end that person's token in every other team too.OAuthRevokeInput.WholeGrant(backend/src/CodeSpace.Core/Services/OAuth/IOAuthClient.cs:75) defaults to true.CredentialService.cs:281) and keeps the grant delete it always sent.ProviderInstanceService.Retirement.cs:141).WholeGrantis false,GitHubOAuthClient.RevokeAsync(GitHubOAuthClient.cs:99) callsDELETE /applications/{client_id}/tokenwith{"access_token": …}. It sends no refresh token, because GitHub has no endpoint that ends a refresh token alone.OAuthTokenRefresher.RevokeIssuedTokensAsync(OAuthTokenRefresher.cs:146) revokes the pair the provider issued for a row that was blanked in the meantime, because nothing stores that pair.ICredentialPayloadReader.IsRetiredAsync(CredentialPayloadReader.cs:17), the same detached read as the payload.backend/src/CodeSpace.Core/Persistence/DbUpFiles/0249_shred_retired_secrets.sql).chk_variable_value_exclusiveis relaxed for deleted rows only.StorageDefaultService.UpdateAsync(StorageDefaultService.cs:83) blanks the envelope that a rotation or a clear replaced, in the same transaction.external_idandowner_pathare kept so an operator can remove a hook still at the provider by hand. Deliveries to it are refused as retired.NOT VALIDstatements and says to re-run 0126's and 0173's guard functions verbatim. A test runs those statements on a shredded database.DELETE FROM schemaversions WHERE scriptname = '0249_shred_retired_secrets.sql'first (header,0249_shred_retired_secrets.sql:64-70). Without that step this build could not retire a secret. A test re-applies the script this way.RetiredSecretShredRecurringJob(0 * * * *) sendsShredRetiredSecretsCommand, a non-transactional system sweep. Its handler callsIRetiredSecretShredService.ShredAsync(backend/src/CodeSpace.Core/Services/Credentials/RetiredSecretShredService.cs:35).ListAccessibleRepositoriesQueryimplementsIRequireCredentialSpend.CredentialOwnerOrPermission, shared withIRequireCredentialManagement. Instance Admins keep their Owner standing.No API shape changes. Behaviour changes, all required by the security fix:
DELETE /applications/{client_id}/token, GitLabPOST /oauth/revoke. Neither the delete nor a refresh it overtakes calls GitHub's grant delete.RetiredSecretShredRecurringJob.Test plan
OAuthClientRevokeTests: a recordingHttpMessageHandlerasserts what leaves each client.WholeGrantand hint case.OAuthTokenRefresherTests: a theory checks the pair a refresh got for a row blanked meanwhile. When a Disconnect blanked it, the pair is revoked whole-grant. When a retirement blanked and soft-deleted it, the pair is revoked token-level.OAuthGrantRevokerTests: the scope is passed through on every token.RetiredSecretShredDispatchTests: job, command and handler (Rules 14/16).SharedOAuthAppRevocationFlowTestsruns the realGitHubOAuthClientagainst a loopback GitHub that keeps grants per user and app. Two teams share a client id, and one person is connected in both./token, no/grantis sent, and team B's token stays live./grant.CredentialPayloadReaderFlowTests: through the real detached reader, a row that is Active or Disconnected is not retired; a row that is soft-deleted or gone is.WholeGrant = false) after commit;RetiredSecretShredSweepFlowTests(mixed version, simulated):SweepCommandTransactionFlowTestssends the sweep through the real pipeline.RetiredSecretShredMigrationTests), through the productionDbUpRunneron its own database:DELETE /api/v3/applications/{client_id}/tokenwith its own access token./tokenrevokes: the team's token, then the pair GitHub just issued. No/grantis sent./api/admin/storage-defaults.RecurringJobWorkerSmokeE2ETests: the real worker host registers the sweep and it completes a tick.RetiredSecretShredRecurringJobhas a non-emptyNextExecutiononce every worker runs this build.