Skip to content

Let connections to one host take over each other's repositories - #2133

Merged
ppXD merged 1 commit into
mainfrom
fix/let-connections-to-one-host-take-over-each-others-repositories
Oct 19, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/let-connections-to-one-host-take-over-each-others-repositories

Conversation

@ppXD

@ppXD ppXD commented Oct 19, 2026

Copy link
Copy Markdown
Owner

Summary

  • A team that signs in to one GitLab through two OAuth applications holds two provider connections to that host. A repository bound under one of them through a credential that died could only be taken over by a credential of the same connection: the Re-link dialog filtered by connection and said nothing could take it, both relink commands refused, and neither a connect's carry-forward nor the repair sweep looked past the dead credential's connection. Two connections of a team with the same provider and API root are now interchangeable for a repository (ProviderInstanceHost, built on CredentialUrl.SameRoot, one definition for every door).
  • The Re-link dialog renders a candidate list the backend computes (GET /api/provider-instances/{id}/relink-candidates): the caller's own and the team's credentials that can still authenticate, on the connection and its siblings, the same connection first. A static token past its expiry is not offered, matching how the credential list reads it (RemedyResolution.IsLive). Against an API without the route (any 404 from it) the dialog filters the credential list itself, as before.
  • A candidate on another connection says so beside it: which connection it is on, that picking it moves the repository there, and which teammates' act-as would stop working on it until they connect there (identities belong to a connection). RelinkRepositoryCredentialCommand and RelinkCredentialBindingsCommand accept the credential: the target is asked for the repository by id and must find it at the path CodeSpace has, then the repository's credential and connection move together, its hooks are registered again through the new connection and the old ones retired. The id, project links, triggers and history stay; another host is refused with the message it always got.
  • Connect-time carry-forward, the repair sweep and a Disconnect that names a target follow the same rule, keyed on the same owner. A repository the new connection cannot take stays where it was, and a group hook stays with its connection.
  • No migration. A repository always has a credential of its own connection, so older pods, an older SPA and a rollback read every row this writes.

Test plan

  • Unit: ranking theory over auth type and expiry (an expired static token is not offered; OAuth expiry is ignored), ProviderInstanceHost, CredentialUrl.SameRoot; full unit suite 14849 passed, 1 skipped
  • Integration (real Postgres, real mediator pipeline): both relink doors across sibling connections, probe refusal, different host refused, hooks moved and old ones retired, project links, triggers and the credential owner's act-as row intact, carry-forward and sweep across connections, candidates compared against the credential list's health, teammates whose act-as a move strands named and cross-checked against the act-as resolver before and after the move
  • E2E over HTTP with GitLabDoorkeeperStub: relink across connections, stranded teammates named in the candidates, an expired PAT listed as rejected and not offered, the 404 body an older server sends for a missing route
  • Frontend: RelinkDialog renders the backend's set in its order, names the connection and the move when every candidate is on one other connection, names stranded people, and falls back to its own filter on the real older-server 404; lint, tsc --noEmit, full vitest (2717 passed) and build
  • Staging: with two connections to one GitLab, re-link a repository from a dead credential to the other connection's credential; confirm the row names the connection and the move, that hooks arrive through the new connection, and that a teammate who acted as themselves is asked to connect

A team that signs in to one GitLab through two OAuth applications holds
two provider connections to that host. A repository bound under one of
them through a credential that died could only be taken over by a
credential of that same connection: the Re-link dialog filtered the
credential list by connection and said nothing could take it, the two
relink commands refused, and neither a connect's carry-forward nor the
repair sweep looked past the dead credential's own connection.

Two connections of a team with the same provider and API root are now
interchangeable for a repository (ProviderInstanceHost, built on
CredentialUrl.SameRoot, one definition for every door).

- The Re-link dialog renders a candidate list the backend computes
  (GET /provider-instances/{id}/relink-candidates): the caller's own and
  the team's credentials that can still authenticate, on the connection
  and its siblings, the same connection first. A static token past its
  expiry is not offered, as the credential list already reads it as
  rejected (the one definition RemedyResolution.IsLive). Against an API
  without the route (any 404 from it: a real older server answers with
  its fallback's JSON body, not an empty one) the dialog filters the
  credential list itself, as before.
- A candidate on another connection says so beside it: the connection it
  is on and that picking it moves the repository there, even when it is
  the only candidate. Identities that let a teammate act as themselves
  belong to a connection, so the candidate also names the teammates whose
  act-as would stop working on the repository until they connect there
  (StrandedActAs, read through the act-as resolver's own predicate; one
  more resolver method, ListCandidatesOnInstanceAsync, that the
  per-repository listing now delegates to). Nothing is read for it when
  no candidate is on another connection.
- RelinkRepositoryCredentialCommand and RelinkCredentialBindingsCommand
  accept a credential on a sibling connection. The target is asked for
  the repository by id and must find it at the path CodeSpace has, then
  the repository's credential and connection move together, its hooks
  are registered again through the new connection and the old ones
  retired. The id, project links, triggers and history stay. Another
  host is refused with the message it always got.
- Connect-time carry-forward, the repair sweep and a Disconnect that
  names a target follow the same rule, keyed on the same owner. A
  repository the new connection cannot take (it already holds the
  project, or the credential's scopes cannot register the hook) stays
  where it was; a group hook stays with its connection. A repair whose
  every repository is refused counts as a failure of that credential so
  it backs off instead of holding a place in the pass.
- The service that moves a repository between connections is taken
  lazily by the successor service: it reads Webhooks:BaseUrl when built,
  and connecting or disconnecting a credential must not need it.

No migration. A repository always has a credential of its own
connection, so older pods, an older SPA and a rollback read every row
this writes.
@ppXD
ppXD merged commit 2be27b9 into main Oct 19, 2026
8 checks passed
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