diff --git a/backend/src/CodeSpace.Core/Authorization/TeamStanding.cs b/backend/src/CodeSpace.Core/Authorization/TeamStanding.cs
new file mode 100644
index 000000000..b40e8bd66
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Authorization/TeamStanding.cs
@@ -0,0 +1,43 @@
+using System.Linq.Expressions;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Messages.Constants;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Query;
+
+namespace CodeSpace.Core.Authorization;
+
+///
+/// Standing in a team, asked of anyone rather than of the caller: a membership row in the team, or the instance Admin
+/// role, which has the standing of an Owner in every team — what grants the caller.
+/// The role counts while it is switched on, the same role_user → role join the caller's own roles are read
+/// through. Whether the team is live is not part of it; the seams that care ask that separately.
+///
+/// Defined once because several seams ask it of someone other than the caller, and must agree: the departed-token
+/// sweep and a departure (what to retire), the holds a write in someone's name takes (whether to write it), and the
+/// act-as picker and the write it feeds (whom to act as). Read as a membership row alone, the sweep retired every token
+/// an instance Admin had lent a team without joining it.
+///
+public static class TeamStanding
+{
+ ///
+ /// The rule as a filter over rows that each name a team and a person — a credential and whoever lent it, an identity
+ /// and its user — composed into one predicate EF translates. Both selectors must read from the same row.
+ ///
+ public static Expression> Of(CodeSpaceDbContext db, Expression> team, Expression> person)
+ {
+ var row = team.Parameters[0];
+ var personOfRow = ReplacingExpressionVisitor.Replace(person.Parameters[0], row, person.Body);
+ var rule = Rule(db);
+
+ return Expression.Lambda>(ReplacingExpressionVisitor.Replace(rule.Parameters, new[] { team.Body, personOfRow }, rule.Body), row);
+ }
+
+ /// Whether has standing in as of now. Someone with no account has none.
+ public static async Task HeldAsync(CodeSpaceDbContext db, Guid teamId, Guid userId, CancellationToken cancellationToken) =>
+ await db.User.IgnoreQueryFilters().AsNoTracking().Where(u => u.Id == userId).AnyAsync(Of(db, _ => teamId, u => u.Id), cancellationToken).ConfigureAwait(false);
+
+ private static Expression> Rule(CodeSpaceDbContext db) => (teamId, userId) =>
+ db.TeamMembership.Any(m => m.TeamId == teamId && m.UserId == userId) ||
+ db.RoleUser.Any(ru => ru.UserId == userId && db.Role.Any(r => r.Id == ru.RoleId && r.Name == Roles.Admin && r.Status));
+}
diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RetireDepartedMembersCredentialsCommandHandler.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RetireDepartedMembersCredentialsCommandHandler.cs
new file mode 100644
index 000000000..e7ffb98de
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RetireDepartedMembersCredentialsCommandHandler.cs
@@ -0,0 +1,21 @@
+using CodeSpace.Core.Services.Credentials;
+using CodeSpace.Messages.Commands.Credentials;
+using MediatR;
+
+namespace CodeSpace.Core.Handlers.CommandHandlers.Credentials;
+
+/// Thin dispatcher (Rule 16) — the production caller of .
+public sealed class RetireDepartedMembersCredentialsCommandHandler : IRequestHandler
+{
+ private readonly ICredentialRevocationService _revocation;
+
+ public RetireDepartedMembersCredentialsCommandHandler(ICredentialRevocationService revocation)
+ {
+ _revocation = revocation;
+ }
+
+ public async Task Handle(RetireDepartedMembersCredentialsCommand request, CancellationToken cancellationToken)
+ {
+ return await _revocation.RetireDepartedAsync(request.BatchSize, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/backend/src/CodeSpace.Core/Jobs/RecurringJobs/DepartedMemberCredentialRetirementRecurringJob.cs b/backend/src/CodeSpace.Core/Jobs/RecurringJobs/DepartedMemberCredentialRetirementRecurringJob.cs
new file mode 100644
index 000000000..c87016ab3
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Jobs/RecurringJobs/DepartedMemberCredentialRetirementRecurringJob.cs
@@ -0,0 +1,17 @@
+using CodeSpace.Messages.Commands.Credentials;
+using MediatR;
+
+namespace CodeSpace.Core.Jobs.RecurringJobs;
+
+/// Every five minutes: retire the personal tokens still live in a team their person no longer has standing in — every departure from before departures retired them, any an older pod serves during a rolling deploy, and an instance Admin's in a team they are not in once they lose the role (thin Rule-14 dispatcher). A retired token is no longer a candidate, so an idle tick is one small read.
+public sealed class DepartedMemberCredentialRetirementRecurringJob : IRecurringJob
+{
+ private readonly IMediator _mediator;
+
+ public DepartedMemberCredentialRetirementRecurringJob(IMediator mediator) { _mediator = mediator; }
+
+ public string JobId => nameof(DepartedMemberCredentialRetirementRecurringJob);
+ public string CronExpression => "*/5 * * * *";
+
+ public async Task Execute() => await _mediator.Send(new RetireDepartedMembersCredentialsCommand()).ConfigureAwait(false);
+}
diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRevocationService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRevocationService.cs
new file mode 100644
index 000000000..a96bcbed1
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRevocationService.cs
@@ -0,0 +1,176 @@
+using CodeSpace.Core.Authorization;
+using CodeSpace.Core.DependencyInjection;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Workflows;
+using CodeSpace.Messages.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace CodeSpace.Core.Services.Credentials;
+
+public sealed class CredentialRevocationService : ICredentialRevocationService, IScopedDependency
+{
+ private readonly CodeSpaceDbContext _db;
+ private readonly ILogger _logger;
+
+ public CredentialRevocationService(CodeSpaceDbContext db, ILogger logger)
+ {
+ _db = db;
+ _logger = logger;
+ }
+
+ public async Task CascadeAsync(Guid credentialId, CancellationToken cancellationToken)
+ {
+ var affectedRepoCount = await MarkDependentRepositoriesUnauthorisedAsync(credentialId, cancellationToken).ConfigureAwait(false);
+
+ await SoftDeleteDependentIdentitiesAsync(credentialId, cancellationToken).ConfigureAwait(false);
+
+ return affectedRepoCount;
+ }
+
+ public async Task RetireAsync(Credential credential, CancellationToken cancellationToken)
+ {
+ MarkRetired(credential);
+
+ return await CascadeAsync(credential.Id, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// The sweep's own rule, narrowed to one person in one team, so a departure and the sweep can never disagree on what
+ /// goes. Every Personal credential the person lent the team goes, the already-disconnected ones included: the repair
+ /// sweep moves a disconnected credential's bindings to its owner's newest live one, so a single live token left behind
+ /// would hand the team back everything bound through the others. With none left, and no way to connect a new one in
+ /// the name of someone without standing in the team, nothing bound through them follows anywhere. A team-service
+ /// credential is the team's and survives membership changes — it is written owner-less, and is never retired here.
+ ///
+ public async Task RetireDepartedInTeamAsync(Guid teamId, Guid userId, CancellationToken cancellationToken)
+ {
+ var departed = await Departed()
+ .Where(c => c.TeamId == teamId && (c.OwnerUserId ?? c.CreatedBy) == userId)
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var credential in departed) await RetireAsync(credential, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task RetireDepartedAsync(int batchSize, CancellationToken cancellationToken)
+ {
+ var departedIds = await LoadDepartedIdsAsync(batchSize, cancellationToken).ConfigureAwait(false);
+
+ var retired = 0;
+
+ foreach (var credentialId in departedIds)
+ {
+ try
+ {
+ if (await RetireDepartedOneAsync(credentialId, cancellationToken).ConfigureAwait(false)) retired++;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogWarning(ex, "Retiring credential {CredentialId}, whose person is no longer in its team, failed; the pass continues — the credential stays a candidate", credentialId);
+ }
+ }
+
+ return retired;
+ }
+
+ private async Task> LoadDepartedIdsAsync(int batchSize, CancellationToken cancellationToken) =>
+ await Departed().AsNoTracking().OrderBy(c => c.Id).Select(c => c.Id).Take(batchSize).ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ ///
+ /// Re-read under the candidate rule and retired on its own save, so one failure undoes no other. A person who rejoined
+ /// since the candidate list was read keeps the token.
+ ///
+ private async Task RetireDepartedOneAsync(Guid credentialId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var credential = await Departed().SingleOrDefaultAsync(c => c.Id == credentialId, cancellationToken).ConfigureAwait(false);
+
+ if (credential == null) return false;
+
+ var brokenRepositoryCount = await RetireAsync(credential, cancellationToken).ConfigureAwait(false);
+
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ _logger.LogInformation("Retired credential {CredentialId} in team {TeamId}: its person is no longer a member; {RepositoryCount} repositories bound through it now need a new credential", credential.Id, credential.TeamId, brokenRepositoryCount);
+
+ return true;
+ }
+ finally
+ {
+ _db.ChangeTracker.Clear();
+ }
+ }
+
+ ///
+ /// Live Personal credentials whose person has no standing in the credential's team (): no
+ /// membership row there, and not an instance Admin. The person is the owner, or — for a token stored without one, as
+ /// adding a token without naming an owner did before the owner defaulted to the caller — whoever added it.
+ ///
+ /// Only a person with an account. An owner always has one (the column is a foreign key); a creator stamp is not
+ /// one — it can be empty, or name nobody when a script wrote the row — and then nothing says who lent the token or
+ /// whether they left. That token is kept: retiring it would break every repository bound through it on a guess. A
+ /// soft-deleted account is still somebody, and goes like anyone else without standing.
+ ///
+ private IQueryable Departed() =>
+ _db.Credential
+ .Where(c => c.Ownership == CredentialOwnership.Personal && c.DeletedDate == null)
+ .Where(c => _db.User.IgnoreQueryFilters().Any(u => u.Id == (c.OwnerUserId ?? c.CreatedBy)))
+ .Where(ExpressionPredicate.Not(TeamStanding.Of(_db, c => c.TeamId, c => c.OwnerUserId ?? c.CreatedBy)));
+
+ ///
+ /// Clear the token material — nothing in CodeSpace can present it once it is gone — and take the row out of the
+ /// team's credential list. A row retired earlier keeps the date it was first retired on.
+ ///
+ private static void MarkRetired(Credential credential)
+ {
+ credential.EncryptedPayload = string.Empty;
+ credential.Status = CredentialStatus.Revoked;
+ credential.LastValidatedDate = null;
+ credential.DeletedDate ??= DateTimeOffset.UtcNow;
+ }
+
+ ///
+ /// Repositories bound through this credential lose their auth source the moment it's
+ /// revoked. We DON'T cascade-delete them (too destructive — disconnect is often part of
+ /// token rotation, and the user expects to come back), but we DO flip status to
+ /// with a concrete remediation message so the UI
+ /// can show "Needs new credential" instead of pretending the repo is still healthy.
+ ///
+ /// Webhook ingestion is deliberately untouched — webhooks authenticate via the secret we
+ /// registered, not via the OAuth token. Events keep flowing in, and once the user
+ /// re-links to a new credential the repo is fully healthy again with no event gap.
+ ///
+ private async Task MarkDependentRepositoriesUnauthorisedAsync(Guid credentialId, CancellationToken cancellationToken)
+ {
+ var affected = await _db.Repository
+ .Where(r => r.CredentialId == credentialId && r.DeletedDate == null && r.Status != RepositoryStatus.Error)
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var repo in affected)
+ {
+ repo.Status = RepositoryStatus.Error;
+ repo.LastError = "Credential disconnected. Re-link this repository to an active credential of the same provider, or unbind it.";
+ }
+
+ return affected.Count;
+ }
+
+ ///
+ /// A (Model B act-as-user link) is unusable without its backing
+ /// credential, so revoking the credential — e.g. Disconnect on the Personal tab — must soft-delete the
+ /// linked identity too. Otherwise the Connected Identities surface keeps showing a dead link, out of
+ /// sync with the Personal tab. Tracked + saved by the same unit of work as the revoke, and idempotent
+ /// (no live identity ⇒ no-op) so the always-run cascade is safe on an already-revoked credential.
+ ///
+ private async Task SoftDeleteDependentIdentitiesAsync(Guid credentialId, CancellationToken cancellationToken)
+ {
+ var identities = await _db.UserProviderIdentity
+ .Where(i => i.CredentialId == credentialId && i.DeletedDate == null)
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ var now = DateTimeOffset.UtcNow;
+ foreach (var identity in identities) identity.DeletedDate = now;
+ }
+}
diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs
index 38626494d..e50fb9346 100644
--- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs
+++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs
@@ -7,6 +7,7 @@
using CodeSpace.Core.Services.Providers.Capabilities;
using CodeSpace.Core.Services.Providers.Modules;
using CodeSpace.Core.Services.Providers.Scopes;
+using CodeSpace.Core.Services.Teams;
using CodeSpace.Messages.Commands.Credentials;
using CodeSpace.Messages.Credentials;
using CodeSpace.Messages.Dtos.Credentials;
@@ -22,6 +23,7 @@ public sealed class CredentialService : ICredentialService, IScopedDependency
{
private readonly CodeSpaceDbContext _db;
private readonly ICurrentTeam _currentTeam;
+ private readonly ICurrentUser _currentUser;
private readonly IPayloadEncryptor _encryptor;
private readonly ICredentialPayloadSerializer _serializer;
private readonly IOAuthClientRegistry _oauthClients;
@@ -29,12 +31,15 @@ public sealed class CredentialService : ICredentialService, IScopedDependency
private readonly IProviderModuleCatalog _modules;
private readonly IScopeChecker _scopeChecker;
private readonly ICredentialSuccessionService _succession;
+ private readonly ICredentialRevocationService _revocation;
+ private readonly ITeamMemberService _members;
private readonly ILogger _logger;
- public CredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, IOAuthClientRegistry oauthClients, IProviderRegistry registry, IProviderModuleCatalog modules, IScopeChecker scopeChecker, ICredentialSuccessionService succession, ILogger logger)
+ public CredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, IOAuthClientRegistry oauthClients, IProviderRegistry registry, IProviderModuleCatalog modules, IScopeChecker scopeChecker, ICredentialSuccessionService succession, ICredentialRevocationService revocation, ITeamMemberService members, ILogger logger)
{
_db = db;
_currentTeam = currentTeam;
+ _currentUser = currentUser;
_encryptor = encryptor;
_serializer = serializer;
_oauthClients = oauthClients;
@@ -42,6 +47,8 @@ public CredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, IPaylo
_modules = modules;
_scopeChecker = scopeChecker;
_succession = succession;
+ _revocation = revocation;
+ _members = members;
_logger = logger;
}
@@ -149,6 +156,10 @@ public async Task AddAsync(AddCredentialInput input, CancellationToken can
// another team's instances.
await EnsureProviderInstanceInTeamAsync(input.ProviderInstanceId, teamId, cancellationToken).ConfigureAwait(false);
+ var ownerUserId = OwnerOf(input);
+
+ await EnsureOwnerWithinReachAsync(ownerUserId, cancellationToken).ConfigureAwait(false);
+
var json = _serializer.Serialize(input.Payload);
var encrypted = _encryptor.Encrypt(json);
@@ -157,8 +168,7 @@ public async Task AddAsync(AddCredentialInput input, CancellationToken can
Id = Guid.NewGuid(),
TeamId = teamId,
ProviderInstanceId = input.ProviderInstanceId,
- // A team-service credential belongs to the team, not a person — never carry an owner.
- OwnerUserId = input.Ownership == CredentialOwnership.TeamService ? null : input.OwnerUserId,
+ OwnerUserId = ownerUserId,
Ownership = input.Ownership,
AuthType = input.Payload.Type,
DisplayName = input.DisplayName,
@@ -193,8 +203,7 @@ public async Task RevokeAsync(Guid credentialId, Cancell
MarkRevokedLocally(credential, providerError);
}
- var affectedRepoCount = await MarkDependentRepositoriesUnauthorisedAsync(credential.Id, cancellationToken).ConfigureAwait(false);
- await SoftDeleteDependentIdentitiesAsync(credential.Id, cancellationToken).ConfigureAwait(false);
+ var affectedRepoCount = await _revocation.CascadeAsync(credential.Id, cancellationToken).ConfigureAwait(false);
return new RevokeCredentialResult
{
@@ -231,6 +240,25 @@ private async Task EnsureProviderInstanceInTeamAsync(Guid providerInstanceId, Gu
if (!inTeam) throw new KeyNotFoundException($"Provider instance {providerInstanceId} not found.");
}
+ ///
+ /// A team-service credential belongs to the team, not a person — it never carries an owner, whatever it was handed. A
+ /// personal one always does: the person named, or the caller when nobody is, as an OAuth connect already defaults it.
+ /// An owner-less personal token was nobody's to retire when the person who added it left.
+ ///
+ private Guid? OwnerOf(AddCredentialInput input) => input.Ownership == CredentialOwnership.TeamService ? null : input.OwnerUserId ?? _currentUser.Id;
+
+ ///
+ /// Naming an owner is acting on that person: the team lists the token as theirs and its bindings follow their next
+ /// connect. So the owner must have standing in this team — a member, or an instance Admin — and the caller must be them
+ /// or outrank them; an unknown id and one from another team fail alike, before the foreign key could tell them apart.
+ ///
+ private async Task EnsureOwnerWithinReachAsync(Guid? ownerUserId, CancellationToken cancellationToken)
+ {
+ if (ownerUserId is not Guid ownerId) return;
+
+ await _members.EnsureMayActOnAsync(ownerId, cancellationToken).ConfigureAwait(false);
+ }
+
private async Task LoadCredentialAsync(Guid id, CancellationToken cancellationToken) =>
await _db.Credential.Include(c => c.ProviderInstance).FirstOrDefaultAsync(c => c.Id == id && c.DeletedDate == null, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"credential {id} not found");
@@ -312,48 +340,4 @@ private static void MarkRevokedLocally(Credential credential, string? providerEr
credential.LastError = providerError;
credential.LastValidatedDate = null;
}
-
- ///
- /// Repositories bound through this credential lose their auth source the moment it's
- /// revoked. We DON'T cascade-delete them (too destructive — disconnect is often part of
- /// token rotation, and the user expects to come back), but we DO flip status to
- /// with a concrete remediation message so the UI
- /// can show "Needs new credential" instead of pretending the repo is still healthy.
- ///
- /// Webhook ingestion is deliberately untouched — webhooks authenticate via the secret we
- /// registered, not via the OAuth token. Events keep flowing in, and once the user
- /// re-links to a new credential the repo is fully healthy again with no event gap.
- ///
- private async Task MarkDependentRepositoriesUnauthorisedAsync(Guid credentialId, CancellationToken cancellationToken)
- {
- var affected = await _db.Repository
- .Where(r => r.CredentialId == credentialId && r.DeletedDate == null && r.Status != RepositoryStatus.Error)
- .ToListAsync(cancellationToken).ConfigureAwait(false);
-
- foreach (var repo in affected)
- {
- repo.Status = RepositoryStatus.Error;
- repo.LastError = "Credential disconnected. Re-link this repository to an active credential of the same provider, or unbind it.";
- }
-
- return affected.Count;
- }
-
- ///
- /// A (Model B act-as-user link) is unusable without its backing
- /// credential, so revoking the credential — e.g. Disconnect on the Personal tab — must soft-delete the
- /// linked identity too. Otherwise the Connected Identities surface keeps showing a dead link, out of
- /// sync with the Personal tab. This mirrors what UserProviderIdentityService.UnlinkAsync does
- /// from the identity side; tracked + saved by the same unit of work as the revoke, and idempotent
- /// (no live identity ⇒ no-op) so the always-run cascade is safe on an already-revoked credential.
- ///
- private async Task SoftDeleteDependentIdentitiesAsync(Guid credentialId, CancellationToken cancellationToken)
- {
- var identities = await _db.UserProviderIdentity
- .Where(i => i.CredentialId == credentialId && i.DeletedDate == null)
- .ToListAsync(cancellationToken).ConfigureAwait(false);
-
- var now = DateTimeOffset.UtcNow;
- foreach (var identity in identities) identity.DeletedDate = now;
- }
}
diff --git a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRevocationService.cs b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRevocationService.cs
new file mode 100644
index 000000000..ec138c71d
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRevocationService.cs
@@ -0,0 +1,42 @@
+using CodeSpace.Core.Persistence.Entities;
+
+namespace CodeSpace.Core.Services.Credentials;
+
+///
+/// What ending a credential does to everything that leans on it. A Disconnect, an identity unlink, and the owner leaving
+/// the team each end one, and each used to clean up a different subset: an unlink left the repositories bound through the
+/// token Active on a blanked payload, and a departure ended nothing at all. One cascade now serves all three.
+///
+public interface ICredentialRevocationService
+{
+ ///
+ /// Repositories bound through the credential turn Error with a "needs a new credential" message, and the act-as
+ /// identities linked to it are soft-deleted. Idempotent. Tracked changes only — the caller's unit of work saves them.
+ /// Returns how many working repositories it broke.
+ ///
+ Task CascadeAsync(Guid credentialId, CancellationToken cancellationToken);
+
+ ///
+ /// End the credential on this side — blank its token, mark it Revoked and soft-deleted — then cascade. The provider
+ /// is not asked. Tracked changes only. Returns how many working repositories it broke.
+ ///
+ Task RetireAsync(Credential credential, CancellationToken cancellationToken);
+
+ ///
+ /// Retire every Personal credential lent — the ones they own, and
+ /// the owner-less ones they added before a token's owner defaulted to whoever added it — with the act-as identities
+ /// linked to them, unless they still have standing there: what a person lent a team stops being the team's when they
+ /// leave it, and an instance Admin, who stands in every team, has not left it. Their credentials in any other team are
+ /// untouched. Reads standing from the database, so the departure's own membership delete must be saved first. Tracked
+ /// changes only.
+ ///
+ Task RetireDepartedInTeamAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
+
+ ///
+ /// Retire up to personal tokens whose person no longer has standing in the team they were
+ /// lent to — no membership there and not an instance Admin — left behind by departures from before departures retired
+ /// tokens, by an older pod, or by an instance Admin losing the role; each exactly as a departure retires it and saved
+ /// on its own. A token that cannot be attributed to an account is never one. Returns how many were retired.
+ ///
+ Task RetireDepartedAsync(int batchSize, CancellationToken cancellationToken);
+}
diff --git a/backend/src/CodeSpace.Core/Services/OAuth/OAuthFlowService.cs b/backend/src/CodeSpace.Core/Services/OAuth/OAuthFlowService.cs
index ed5528f21..63760c455 100644
--- a/backend/src/CodeSpace.Core/Services/OAuth/OAuthFlowService.cs
+++ b/backend/src/CodeSpace.Core/Services/OAuth/OAuthFlowService.cs
@@ -1,9 +1,11 @@
+using CodeSpace.Core.Authorization;
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Credentials;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Core.Services.Providers.Identity;
+using CodeSpace.Core.Services.Teams;
using CodeSpace.Core.Settings.Application;
using CodeSpace.Core.Settings.OAuth;
using CodeSpace.Messages.Commands.Credentials;
@@ -30,10 +32,11 @@ public sealed class OAuthFlowService : IOAuthFlowService, IScopedDependency
private readonly ICurrentUser _currentUser;
private readonly IUserProviderIdentityService _identityService;
private readonly ICredentialSuccessionService _succession;
+ private readonly ITeamMemberService _members;
private readonly OAuthCallbackUrlSetting _callbackUrlSetting;
private readonly PublicBaseUrlSetting _publicBaseUrl;
- public OAuthFlowService(CodeSpaceDbContext db, IOAuthClientRegistry oauthClients, IOAuthStateStore stateStore, IPkceGenerator pkce, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, ICurrentTeam currentTeam, ICurrentUser currentUser, IUserProviderIdentityService identityService, ICredentialSuccessionService succession, OAuthCallbackUrlSetting callbackUrlSetting, PublicBaseUrlSetting publicBaseUrl)
+ public OAuthFlowService(CodeSpaceDbContext db, IOAuthClientRegistry oauthClients, IOAuthStateStore stateStore, IPkceGenerator pkce, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, ICurrentTeam currentTeam, ICurrentUser currentUser, IUserProviderIdentityService identityService, ICredentialSuccessionService succession, ITeamMemberService members, OAuthCallbackUrlSetting callbackUrlSetting, PublicBaseUrlSetting publicBaseUrl)
{
_db = db;
_oauthClients = oauthClients;
@@ -45,6 +48,7 @@ public OAuthFlowService(CodeSpaceDbContext db, IOAuthClientRegistry oauthClients
_currentUser = currentUser;
_identityService = identityService;
_succession = succession;
+ _members = members;
_callbackUrlSetting = callbackUrlSetting;
_publicBaseUrl = publicBaseUrl;
}
@@ -55,6 +59,7 @@ public async Task InitAsync(Guid providerInstanceId,
var instance = await LoadOwnedInstanceAsync(providerInstanceId, cancellationToken).ConfigureAwait(false);
var (clientId, _) = ReadOAuthClientCredentials(instance);
+ var ownerUserId = await ResolveOwnerWithinReachAsync(intendedOwnerUserId, cancellationToken).ConfigureAwait(false);
var pkce = _pkce.Generate();
var browserNonce = OAuthBrowserNonce.Mint();
@@ -66,7 +71,7 @@ public async Task InitAsync(Guid providerInstanceId,
InitiatorUserId = _currentUser.Id!.Value,
CodeVerifier = pkce.Verifier,
IntendedDisplayName = displayName,
- IntendedOwnerUserId = intendedOwnerUserId ?? _currentUser.Id!.Value,
+ IntendedOwnerUserId = ownerUserId,
ReturnUrl = returnUrl,
BrowserNonceHash = OAuthBrowserNonce.Hash(browserNonce),
RequestedScopes = scopes ?? instance.OauthDefaultScopes,
@@ -94,7 +99,12 @@ public async Task CompleteAsync(string state, str
var instance = await LoadInstanceAsync(pending.ProviderInstanceId, cancellationToken).ConfigureAwait(false);
var (clientId, clientSecret) = ReadOAuthClientCredentials(instance);
+ await EnsureOwnerStillInTeamAsync(pending, cancellationToken).ConfigureAwait(false);
+
var token = await ExchangeCodeAsync(instance, clientId, clientSecret, code, pending.CodeVerifier, cancellationToken).ConfigureAwait(false);
+
+ await HoldOwnerInTeamAsync(pending, cancellationToken).ConfigureAwait(false);
+
var credential = PersistCredential(pending, instance, token);
await _succession.CarryForwardAsync(credential, cancellationToken).ConfigureAwait(false);
@@ -142,6 +152,55 @@ private async Task LinkActorIdentityBestEffortAsync(ProviderInstance instance, C
}
}
+ ///
+ /// Who the credential will belong to — the caller unless they name someone — and whom an OAuth connect links as an
+ /// act-as identity. Naming them is acting on them, so they must have standing in this team and be within the caller's
+ /// reach, and that is checked for the caller too: a personal token in a team is lent by someone who stands in it — a
+ /// member, or an instance Admin, who stands in every team.
+ ///
+ private async Task ResolveOwnerWithinReachAsync(Guid? intendedOwnerUserId, CancellationToken cancellationToken)
+ {
+ var ownerUserId = intendedOwnerUserId ?? _currentUser.Id!.Value;
+
+ await _members.EnsureMayActOnAsync(ownerUserId, cancellationToken).ConfigureAwait(false);
+
+ return ownerUserId;
+ }
+
+ ///
+ /// The callback can land up to after Init, and the owner may have left or been removed — or, an
+ /// instance Admin outside the team, lost the role — in between. A token minted for them now would be a live credential
+ /// of someone without standing in the team — one the team could bind through, and one the repair sweep would hand their
+ /// retired bindings to — so the code is not exchanged at all. Runs anonymous, so it asks standing directly
+ /// () rather than through the caller's reach.
+ ///
+ private async Task EnsureOwnerStillInTeamAsync(OAuthPendingState pending, CancellationToken cancellationToken)
+ {
+ if (pending.IntendedOwnerUserId is not Guid ownerId) return;
+
+ var teamLive = await _db.Team.AsNoTracking().AnyAsync(t => t.Id == pending.TeamId && t.DeletedDate == null, cancellationToken).ConfigureAwait(false);
+
+ if (!teamLive || !await TeamStanding.HeldAsync(_db, pending.TeamId, ownerId, cancellationToken).ConfigureAwait(false))
+ throw OwnerLeftTheTeam();
+ }
+
+ ///
+ /// The check above runs before the provider round-trip, and the owner can leave during it. So the owner's standing is
+ /// asked again once the token is back, and a member's held until this callback commits: a departure that committed
+ /// during the exchange refuses the token here, and one that starts now waits for the commit and then retires the token
+ /// with the rest of theirs. The refused token is not revoked at the provider — revoking an OAuth grant there can end
+ /// the person's tokens elsewhere — it is simply never stored.
+ ///
+ private async Task HoldOwnerInTeamAsync(OAuthPendingState pending, CancellationToken cancellationToken)
+ {
+ if (pending.IntendedOwnerUserId is not Guid ownerId) return;
+
+ if (!await _members.HoldMembershipAsync(pending.TeamId, ownerId, cancellationToken).ConfigureAwait(false))
+ throw OwnerLeftTheTeam();
+ }
+
+ private static OAuthCallbackException OwnerLeftTheTeam() => new("the person this connection was started for no longer has standing in the team");
+
private async Task LoadOwnedInstanceAsync(Guid id, CancellationToken cancellationToken)
{
var instance = await _db.ProviderInstance.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id && p.DeletedDate == null, cancellationToken).ConfigureAwait(false)
diff --git a/backend/src/CodeSpace.Core/Services/Providers/Identity/ActorIdentityResolver.cs b/backend/src/CodeSpace.Core/Services/Providers/Identity/ActorIdentityResolver.cs
index 21bc41b92..9e4777d27 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/Identity/ActorIdentityResolver.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/Identity/ActorIdentityResolver.cs
@@ -1,3 +1,5 @@
+using System.Linq.Expressions;
+using CodeSpace.Core.Authorization;
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
@@ -16,15 +18,17 @@ public ActorIdentityResolver(CodeSpaceDbContext db)
_db = db;
}
+ /// A live identity on a live Active credential, in the live team that owns the provider instance.
+ private static readonly Expression> Usable = i =>
+ i.DeletedDate == null &&
+ i.Credential.DeletedDate == null &&
+ i.Credential.Status == CredentialStatus.Active &&
+ i.ProviderInstance.Team.DeletedDate == null;
+
public async Task ResolveAsync(Guid actorUserId, Guid providerInstanceId, CancellationToken cancellationToken) =>
- await _db.UserProviderIdentity
- .Include(i => i.Credential)
- .SingleOrDefaultAsync(i =>
- i.UserId == actorUserId &&
- i.ProviderInstanceId == providerInstanceId &&
- i.DeletedDate == null &&
- i.Credential.DeletedDate == null &&
- i.Credential.Status == CredentialStatus.Active, cancellationToken).ConfigureAwait(false);
+ await MayAct(_db.UserProviderIdentity.Include(i => i.Credential))
+ .Where(i => i.UserId == actorUserId && i.ProviderInstanceId == providerInstanceId)
+ .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false);
public async Task> ListCandidatesAsync(Guid repositoryId, Guid teamId, CancellationToken cancellationToken)
{
@@ -36,24 +40,10 @@ public async Task> ListCandidatesAsync(Guid
if (providerInstanceId == null) return Array.Empty();
- // Team users — only they may be offered as an author. The owner used to be fetched separately
- // and appended, because they could be recorded on the team row alone; they hold a membership
- // row like everyone else now.
- var teamUserIds = await _db.TeamMembership.AsNoTracking()
- .Where(m => m.TeamId == teamId && m.Team.DeletedDate == null)
- .Select(m => m.UserId)
- .ToListAsync(cancellationToken).ConfigureAwait(false);
-
- if (teamUserIds.Count == 0) return Array.Empty();
-
- // Every teammate with a LIVE, usable identity on this provider instance — the EXACT predicate
- // ResolveAsync uses, so every candidate is guaranteed to resolve (no throw) at write time.
- var identities = await _db.UserProviderIdentity.AsNoTracking()
- .Where(i => i.ProviderInstanceId == providerInstanceId &&
- i.DeletedDate == null &&
- i.Credential.DeletedDate == null &&
- i.Credential.Status == CredentialStatus.Active &&
- teamUserIds.Contains(i.UserId))
+ // Every teammate who may act on this provider instance — the EXACT predicate ResolveAsync uses, so every
+ // candidate is guaranteed to resolve (no throw) at write time.
+ var identities = await MayAct(_db.UserProviderIdentity.AsNoTracking())
+ .Where(i => i.ProviderInstanceId == providerInstanceId)
.Select(i => new { i.UserId, i.ProviderUsername, i.ProviderUserId, i.AvatarUrl })
.ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -80,4 +70,18 @@ public async Task> ListCandidatesAsync(Guid
.OrderBy(c => c.Name)
.ToList();
}
+
+ ///
+ /// Whether an identity may act, and the ONLY place that says so: the picker and the write both filter through it, so
+ /// every candidate the picker offers resolves and nobody the picker hides can be written through. A usable identity,
+ /// and a user who still has standing () in the team that owns the provider instance — a
+ /// membership row there, or the instance Admin role, which stands in every team.
+ ///
+ /// The standing half, and the live team, are what the write used to lack; only the picker asked them. Leaving or
+ /// being removed deletes the membership row and nothing else could see it here, so a workflow any Member authored could
+ /// name a departed person in actAsUserId and write through their personal token. The instance is team-scoped,
+ /// so its team is the one whose standing counts — no caller has to pass it.
+ ///
+ private IQueryable MayAct(IQueryable identities) =>
+ identities.Where(Usable).Where(TeamStanding.Of(_db, i => i.ProviderInstance.TeamId, i => i.UserId));
}
diff --git a/backend/src/CodeSpace.Core/Services/Providers/Identity/IActorIdentityResolver.cs b/backend/src/CodeSpace.Core/Services/Providers/Identity/IActorIdentityResolver.cs
index 6cb2f3ce1..0f1a53c32 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/Identity/IActorIdentityResolver.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/Identity/IActorIdentityResolver.cs
@@ -16,8 +16,10 @@ public interface IActorIdentityResolver
{
///
/// The user's live, usable identity on the instance, or null. "Usable" = the identity is not
- /// soft-deleted AND its linked credential is present and Active — so a revoked / expired / removed
- /// credential resolves to null exactly like "never linked".
+ /// soft-deleted, its linked credential is present and Active, AND the user still has standing in the
+ /// live team that owns the instance — a membership row there, or the instance Admin role — so a
+ /// revoked / expired / removed credential, or a person who left the team, resolves to null exactly
+ /// like "never linked".
///
Task ResolveAsync(Guid actorUserId, Guid providerInstanceId, CancellationToken cancellationToken);
diff --git a/backend/src/CodeSpace.Core/Services/Providers/Identity/IUserProviderIdentityService.cs b/backend/src/CodeSpace.Core/Services/Providers/Identity/IUserProviderIdentityService.cs
index 5f572d962..9d67d3a99 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/Identity/IUserProviderIdentityService.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/Identity/IUserProviderIdentityService.cs
@@ -22,7 +22,7 @@ public interface IUserProviderIdentityService
/// The caller's live linked identities (one per provider instance), newest first.
Task> ListMineAsync(CancellationToken cancellationToken);
- /// Unlink one of the caller's identities (soft-delete the identity + its credential). No-op if already gone.
+ /// Unlink one of the caller's identities: soft-delete it and retire its credential, cascading to the repositories bound through it. No-op if already gone.
Task UnlinkAsync(Guid identityId, CancellationToken cancellationToken);
///
@@ -30,8 +30,10 @@ public interface IUserProviderIdentityService
/// backed by an ALREADY-created credential (the OAuth-complete path — the credential is built by
/// the OAuth flow, not here). Probes the credential for the provider user, then either re-points
/// the user's existing live identity to this credential or creates a new one. NON-destructive: it
- /// never revokes another credential (unlike the PAT replace-in-place path). The caller persists —
- /// no SaveChanges here. Throws if the probe fails (caller decides whether that's fatal).
+ /// never revokes another credential (unlike the PAT replace-in-place path). Does nothing unless
+ /// is who connected the credential — nobody links an identity for someone
+ /// else. The caller persists — no SaveChanges here. Throws if the probe fails (caller decides whether
+ /// that's fatal).
///
Task EnsureIdentityForCredentialAsync(ProviderInstance instance, Credential credential, Guid userId, CancellationToken cancellationToken);
}
diff --git a/backend/src/CodeSpace.Core/Services/Providers/Identity/UserProviderIdentityService.cs b/backend/src/CodeSpace.Core/Services/Providers/Identity/UserProviderIdentityService.cs
index 8e24fce15..ec7ed6faf 100644
--- a/backend/src/CodeSpace.Core/Services/Providers/Identity/UserProviderIdentityService.cs
+++ b/backend/src/CodeSpace.Core/Services/Providers/Identity/UserProviderIdentityService.cs
@@ -1,9 +1,11 @@
+using CodeSpace.Core.Authorization;
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Credentials;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Core.Services.Providers.Capabilities;
+using CodeSpace.Core.Services.Teams;
using CodeSpace.Messages.Credentials;
using CodeSpace.Messages.Dtos.Providers;
using CodeSpace.Messages.Enums;
@@ -20,8 +22,10 @@ public sealed class UserProviderIdentityService : IUserProviderIdentityService,
private readonly ICredentialPayloadSerializer _serializer;
private readonly IProviderRegistry _registry;
private readonly ICredentialSuccessionService _succession;
+ private readonly ICredentialRevocationService _revocation;
+ private readonly ITeamMemberService _members;
- public UserProviderIdentityService(CodeSpaceDbContext db, ICurrentUser currentUser, ICurrentTeam currentTeam, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, IProviderRegistry registry, ICredentialSuccessionService succession)
+ public UserProviderIdentityService(CodeSpaceDbContext db, ICurrentUser currentUser, ICurrentTeam currentTeam, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, IProviderRegistry registry, ICredentialSuccessionService succession, ICredentialRevocationService revocation, ITeamMemberService members)
{
_db = db;
_currentUser = currentUser;
@@ -30,6 +34,8 @@ public UserProviderIdentityService(CodeSpaceDbContext db, ICurrentUser currentUs
_serializer = serializer;
_registry = registry;
_succession = succession;
+ _revocation = revocation;
+ _members = members;
}
public async Task LinkByPatAsync(Guid providerInstanceId, string accessToken, CancellationToken cancellationToken)
@@ -44,6 +50,8 @@ public async Task LinkByPatAsync(Guid providerInsta
var credential = BuildPersonalCredential(instance, userId, teamId, accessToken);
var profile = await ProbeOrThrowAsync(instance, credential, cancellationToken).ConfigureAwait(false);
+ await HoldCallerInTeamAsync(userId, teamId, cancellationToken).ConfigureAwait(false);
+
credential.DisplayName = $"{instance.Provider} · {profile.AuthenticatedUserName}";
// Persist the token's real granted scopes (the probe reads them live) so capability warnings
@@ -94,20 +102,20 @@ public async Task UnlinkAsync(Guid identityId, CancellationToken cancellationTok
if (identity == null) return;
- var now = DateTimeOffset.UtcNow;
- identity.DeletedDate = now;
+ identity.DeletedDate = DateTimeOffset.UtcNow;
- // Clear the token material + soft-delete the backing credential — an unlinked identity must
- // leave nothing usable at rest.
- identity.Credential.DeletedDate = now;
- identity.Credential.Status = CredentialStatus.Revoked;
- identity.Credential.EncryptedPayload = string.Empty;
+ // Retire the backing credential through the same cascade a Disconnect runs — an unlinked identity must leave
+ // nothing usable at rest, and a repository bound through its token must say it needs a new one rather than
+ // stay Active on a blanked payload. Still the caller's own token, from whichever team they ask.
+ await _revocation.RetireAsync(identity.Credential, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task EnsureIdentityForCredentialAsync(ProviderInstance instance, Credential credential, Guid userId, CancellationToken cancellationToken)
{
+ if (!ConnectedBy(credential, userId)) return;
+
var profile = await ProbeOrThrowAsync(instance, credential, cancellationToken).ConfigureAwait(false);
var existing = await _db.UserProviderIdentity
@@ -129,6 +137,26 @@ public async Task EnsureIdentityForCredentialAsync(ProviderInstance instance, Cr
await _db.UserProviderIdentity.AddAsync(identity, cancellationToken).ConfigureAwait(false);
}
+ ///
+ /// The request was let in on the caller's standing, but the provider check runs between that and the insert, and the
+ /// caller can leave — or be removed — during it. Asked again and, for a member, held until this link commits: a
+ /// departure that already committed refuses the link, one that starts now waits and then retires the new token with
+ /// the rest of theirs. A personal token in a team is lent by someone who stands in it — a member, or an instance Admin,
+ /// who stands in every team ().
+ ///
+ private async Task HoldCallerInTeamAsync(Guid userId, Guid teamId, CancellationToken cancellationToken)
+ {
+ if (!await _members.HoldMembershipAsync(teamId, userId, cancellationToken).ConfigureAwait(false))
+ throw new TenantAccessDeniedException(userId, teamId, "user is not a member of this team");
+ }
+
+ ///
+ /// An identity says "this provider account speaks for this person", and only the person can say that — by connecting
+ /// the account themselves. A token someone else connected in their name is whatever account THAT caller signed into
+ /// at the provider; linking it would let them choose who the person's writes are attributed to.
+ ///
+ private static bool ConnectedBy(Credential credential, Guid userId) => credential.CreatedBy == userId;
+
private async Task LoadInstanceAsync(Guid providerInstanceId, Guid teamId, CancellationToken cancellationToken) =>
await _db.ProviderInstance
.SingleOrDefaultAsync(i => i.Id == providerInstanceId && i.TeamId == teamId, cancellationToken).ConfigureAwait(false)
diff --git a/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.cs b/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.cs
index 2a64102ea..9a79dc10e 100644
--- a/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.cs
+++ b/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.cs
@@ -259,9 +259,10 @@ private async Task LoadProviderInstanceAsync(BindContext ctx, Cance
return ctx with { Instance = instance };
}
+ /// A soft-deleted credential — unlinked, or retired when its owner left the team — is gone, and nothing binds through it again.
private async Task LoadCredentialAsync(BindContext ctx, CancellationToken cancellationToken)
{
- var credential = await _db.Credential.SingleOrDefaultAsync(c => c.Id == ctx.Request.CredentialId && c.TeamId == ctx.Request.TeamId, cancellationToken).ConfigureAwait(false)
+ var credential = await _db.Credential.SingleOrDefaultAsync(c => c.Id == ctx.Request.CredentialId && c.TeamId == ctx.Request.TeamId && c.DeletedDate == null, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException($"Credential {ctx.Request.CredentialId} not found in team {ctx.Request.TeamId}");
return ctx with { Credential = credential };
diff --git a/backend/src/CodeSpace.Core/Services/Teams/ITeamMemberService.cs b/backend/src/CodeSpace.Core/Services/Teams/ITeamMemberService.cs
index 2901e1b2c..0a772ee8f 100644
--- a/backend/src/CodeSpace.Core/Services/Teams/ITeamMemberService.cs
+++ b/backend/src/CodeSpace.Core/Services/Teams/ITeamMemberService.cs
@@ -12,4 +12,21 @@ public interface ITeamMemberService
Task LeaveAsync(CancellationToken cancellationToken);
Task TransferOwnershipAsync(Guid toUserId, CancellationToken cancellationToken);
+
+ ///
+ /// Throws unless has standing in the current team — a member, or an instance Admin, who
+ /// stands in every team as an Owner — and the caller may act on them: themselves, or someone below them. A user
+ /// without standing is refused exactly like an id that names nobody. Holds their standing as
+ /// does.
+ ///
+ Task EnsureMayActOnAsync(Guid userId, CancellationToken cancellationToken);
+
+ ///
+ /// Whether has standing in the live team — a membership row there,
+ /// or the instance Admin role — and, for a member, keeps them one until the caller's transaction ends: their leaving or
+ /// removal waits for it to commit, then retires what it wrote in their name. For a write in someone's name that must
+ /// not land after they have gone, such as a token they own; the check before a slow provider call is not enough on its
+ /// own. Outside a transaction it holds nothing.
+ ///
+ Task HoldMembershipAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
}
diff --git a/backend/src/CodeSpace.Core/Services/Teams/TeamMemberService.cs b/backend/src/CodeSpace.Core/Services/Teams/TeamMemberService.cs
index ff2a10e69..91691559f 100644
--- a/backend/src/CodeSpace.Core/Services/Teams/TeamMemberService.cs
+++ b/backend/src/CodeSpace.Core/Services/Teams/TeamMemberService.cs
@@ -2,6 +2,7 @@
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Credentials;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Enums;
@@ -30,13 +31,15 @@ public sealed class TeamMemberService : ITeamMemberService, IScopedDependency
private readonly ICurrentUser _currentUser;
private readonly ICurrentTeam _currentTeam;
private readonly TeamMembershipResolver _membership;
+ private readonly ICredentialRevocationService _revocation;
- public TeamMemberService(CodeSpaceDbContext db, ICurrentUser currentUser, ICurrentTeam currentTeam, TeamMembershipResolver membership)
+ public TeamMemberService(CodeSpaceDbContext db, ICurrentUser currentUser, ICurrentTeam currentTeam, TeamMembershipResolver membership, ICredentialRevocationService revocation)
{
_db = db;
_currentUser = currentUser;
_currentTeam = currentTeam;
_membership = membership;
+ _revocation = revocation;
}
public async Task ChangeRoleAsync(Guid userId, TeamRole role, CancellationToken cancellationToken)
@@ -80,6 +83,48 @@ public async Task LeaveAsync(CancellationToken cancellationToken)
await RemoveMembershipAsync(teamId, membership, cancellationToken).ConfigureAwait(false);
}
+ ///
+ /// The same reach as every other act on a member, asked by things that act on a person without changing their
+ /// membership — naming them as a credential's owner, say. They must have standing in this live team, and the caller
+ /// must be them or outrank them. Someone without standing fails exactly like an id that names nobody. Held until the
+ /// caller commits (), so what it writes in their name cannot land after they have gone.
+ ///
+ public async Task EnsureMayActOnAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ var teamId = RequireTeam();
+
+ if (!await HoldMembershipAsync(teamId, userId, cancellationToken).ConfigureAwait(false))
+ throw new KeyNotFoundException("that person is not a member of this team");
+
+ var actor = await _membership.ResolveRoleAsync(teamId, cancellationToken).ConfigureAwait(false);
+ var target = await ResolveStandingRoleAsync(teamId, userId, cancellationToken).ConfigureAwait(false);
+
+ EnsureMayActOn(actor, target, isSelf: userId == RequireUser());
+ }
+
+ ///
+ /// Whether they have standing (), held until the caller's transaction ends. A member's
+ /// membership row is share-locked, after a share lock on the live team's row: it conflicts only with a departure's
+ /// delete of that membership row () and the owner guard's lock on the team row
+ /// (), so everything else in the team goes on beside it. Team row first, as an Owner's
+ /// departure takes them: holding only the membership row, the write's own insert would wait on the team row that
+ /// departure holds while the departure waits on the membership row.
+ ///
+ /// An instance Admin outside the team has no row to hold, and none is held for the role: nothing in the app
+ /// takes it away (role_user is written by migrations and by hand), so there is no departure to serialise with.
+ /// A demotion that commits before this answers is refused here; one that commits after leaves a token the departure
+ /// sweep retires on its next tick — the same window every token an ex-Admin lent already has between the demotion and
+ /// that tick, and one in which nobody can act as them, because the act-as resolver asks standing at the write.
+ ///
+ public async Task HoldMembershipAsync(Guid teamId, Guid userId, CancellationToken cancellationToken)
+ {
+ if (!await ShareLockLiveTeamAsync(teamId, cancellationToken).ConfigureAwait(false)) return false;
+
+ await ShareLockMembershipAsync(teamId, userId, cancellationToken).ConfigureAwait(false);
+
+ return await TeamStanding.HeldAsync(_db, teamId, userId, cancellationToken).ConfigureAwait(false);
+ }
+
///
/// Hands the team over in one step: the recipient becomes Owner and the outgoing owner becomes
/// Admin.
@@ -145,6 +190,22 @@ private static void EnsureMayGrant(TeamRole actor, TeamRole role)
if (TeamRoleRank.Of(role) > TeamRoleRank.Of(actor)) throw new RoleOutranksActorException(actor, role);
}
+ ///
+ /// Leaving and being removed end the same things, in the same transaction: the membership row, and every provider
+ /// token the person lent the team. A personal token is that person's account — kept after they go, the team could
+ /// still list it, browse what it reaches, bind more through it, and act as them. What is bound through it turns "needs
+ /// a new credential" rather than silently keep a token nobody here may use.
+ ///
+ /// The row's delete is saved before the retirement reads what goes, because what goes is decided by the
+ /// departure sweep's own rule — no standing left in the team — and an instance Admin still stands in it without the
+ /// row, so keeps what they lent. The mediator's transaction spans both saves.
+ ///
+ /// The delete is also what serialises a departure with every write in the person's name. It takes the row's
+ /// exclusive lock, so it waits for each write holding that row () to commit, and keeps
+ /// any new one out until this departure commits — after which the row is gone and the write refuses. The retirement
+ /// reads after the delete, so it sees a token such a write committed while the departure waited, and retires it with
+ /// the rest instead of leaving it live for someone outside the team.
+ ///
private async Task RemoveMembershipAsync(Guid teamId, TeamMembership membership, CancellationToken cancellationToken)
{
if (membership.Role == TeamRole.Owner) await EnsureAnotherOwnerRemainsAsync(teamId, membership.UserId, cancellationToken).ConfigureAwait(false);
@@ -152,6 +213,10 @@ private async Task RemoveMembershipAsync(Guid teamId, TeamMembership membership,
_db.TeamMembership.Remove(membership);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ await _revocation.RetireDepartedInTeamAsync(teamId, membership.UserId, cancellationToken).ConfigureAwait(false);
+
+ await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
///
@@ -187,6 +252,24 @@ private async Task EnsureAnotherOwnerRemainsAsync(Guid teamId, Guid leavingUserI
private async Task LockTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
await _db.Database.ExecuteSqlInterpolatedAsync($"SELECT id FROM team WHERE id = {teamId} FOR UPDATE", cancellationToken).ConfigureAwait(false);
+ private async Task ShareLockLiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
+ (await _db.Database.SqlQuery($"SELECT 1 AS \"Value\" FROM team WHERE id = {teamId} AND deleted_date IS NULL FOR KEY SHARE").ToListAsync(cancellationToken).ConfigureAwait(false)).Count > 0;
+
+ private async Task ShareLockMembershipAsync(Guid teamId, Guid userId, CancellationToken cancellationToken) =>
+ await _db.Database.SqlQuery($"SELECT 1 AS \"Value\" FROM team_membership WHERE team_id = {teamId} AND user_id = {userId} FOR KEY SHARE").ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ ///
+ /// The rank a person with standing is acted on at: their membership's role, read as and
+ /// read it; or, for someone who stands in the team without one — only an instance Admin can —
+ /// an Owner's, the rank gives that role in every team.
+ ///
+ private async Task ResolveStandingRoleAsync(Guid teamId, Guid userId, CancellationToken cancellationToken)
+ {
+ var membership = await _db.TeamMembership.AsNoTracking().SingleOrDefaultAsync(m => m.TeamId == teamId && m.UserId == userId, cancellationToken).ConfigureAwait(false);
+
+ return membership?.Role ?? TeamRole.Owner;
+ }
+
private async Task EnsureTeamLiveAsync(Guid teamId, CancellationToken cancellationToken)
{
var live = await _db.Team.AsNoTracking().AnyAsync(t => t.Id == teamId && t.DeletedDate == null, cancellationToken).ConfigureAwait(false);
diff --git a/backend/src/CodeSpace.Messages/Commands/Credentials/RetireDepartedMembersCredentialsCommand.cs b/backend/src/CodeSpace.Messages/Commands/Credentials/RetireDepartedMembersCredentialsCommand.cs
new file mode 100644
index 000000000..6f3ee8765
--- /dev/null
+++ b/backend/src/CodeSpace.Messages/Commands/Credentials/RetireDepartedMembersCredentialsCommand.cs
@@ -0,0 +1,25 @@
+using CodeSpace.Messages.Mediation;
+
+namespace CodeSpace.Messages.Commands.Credentials;
+
+///
+/// Retire every personal token still live in a team its person no longer has standing in — no membership there, and not
+/// an instance Admin, who stands in every team as an Owner does — the person being the token's owner, or, for a token
+/// stored without one, whoever added it; a token whose creator stamp names no account is never retired on a guess.
+/// Leaving or being removed retires them in the same transaction; this retires the ones a departure left behind anyway:
+/// every departure from before departures retired anything, any an older pod serves during a rolling deploy, and every
+/// token an instance Admin lent a team they are not in, once they lose the role — the app never takes the role away, so
+/// no departure retires those. Each token is retired exactly as a departure retires it — blanked, Revoked,
+/// soft-deleted, its repositories marked "needs a new credential", its act-as identities soft-deleted — and the provider
+/// is not asked. Idempotent and bounded: a retired token is no longer a candidate, so the backlog only shrinks. NOT
+/// tenant-scoped: a system-wide repair that runs without an actor context (mirrors RepairDisconnectedCredentialBindingsCommand).
+///
+/// NOT transactional (): each token is retired and saved on its own, and a
+/// token that fails stays a candidate while the pass carries on. One transaction around the pass would let one failing
+/// token undo every other one's retirement.
+///
+public sealed record RetireDepartedMembersCredentialsCommand : ICommand, INonTransactionalCommand
+{
+ /// Tokens retired per tick — bounds each pass.
+ public int BatchSize { get; init; } = 50;
+}
diff --git a/backend/src/CodeSpace.Messages/Credentials/AddCredentialInput.cs b/backend/src/CodeSpace.Messages/Credentials/AddCredentialInput.cs
index ca1d33ca4..1cddac8d8 100644
--- a/backend/src/CodeSpace.Messages/Credentials/AddCredentialInput.cs
+++ b/backend/src/CodeSpace.Messages/Credentials/AddCredentialInput.cs
@@ -10,7 +10,7 @@ public sealed record AddCredentialInput
{
public required Guid ProviderInstanceId { get; init; }
- /// The owning user for a Personal credential; ignored (forced null) for TeamService.
+ /// The owning user for a Personal credential — the caller when unset; ignored (forced null) for TeamService.
public Guid? OwnerUserId { get; init; }
public required string DisplayName { get; init; }
diff --git a/backend/tests/CodeSpace.E2ETests/Auth/DepartedMemberCredentialE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Auth/DepartedMemberCredentialE2ETests.cs
new file mode 100644
index 000000000..6f3ed3a87
--- /dev/null
+++ b/backend/tests/CodeSpace.E2ETests/Auth/DepartedMemberCredentialE2ETests.cs
@@ -0,0 +1,581 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Autofac;
+using Autofac.Extensions.DependencyInjection;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Credentials;
+using CodeSpace.Core.Services.OAuth;
+using CodeSpace.E2ETests.Infrastructure;
+using CodeSpace.IntegrationTests.Binding;
+using CodeSpace.Messages.Commands.Credentials;
+using CodeSpace.Messages.Constants;
+using CodeSpace.Messages.Credentials;
+using CodeSpace.Messages.Enums;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+
+namespace CodeSpace.E2ETests.Auth;
+
+///
+/// A person's own provider token, lent to a team while they belong to it, through the whole HTTP surface — and what is
+/// left of it once they leave or are removed.
+///
+/// Tier: 🟢 High-fidelity (Rule 12) — the real ASP.NET pipeline (routing, JWT auth, the X-Team-Id scope,
+/// model binding, the controllers, the exception filter, the mediator and its authorization behaviors), the real
+/// workflow engine drained in-process, and real Postgres. One documented seam: the provider is the Git test provider,
+/// which answers whoami, lists three fixed repositories, and records every PR open in
+/// — so "the write never reached the provider" is observed, not inferred —
+/// with a loopback OAuth client for its authorize URL. Two teams per test: the departing person is a Member of one and
+/// the Owner of their own.
+///
+/// Must hold, for both ways out (removed, left): the token stops being the team's — not listed, not browsable,
+/// not bindable, not usable to act as them, not even by a run launched before they went — while the token they lent
+/// their other team keeps working; the same for a token added without naming an owner, and for a departure from before
+/// departures retired tokens once the departure sweep has run; an owner id outside the caller's reach is refused on both
+/// connect paths, with one answer for "elsewhere" and "nowhere"; and unlinking an identity breaks, visibly, the
+/// repositories bound through its token. And an instance Admin, who stands in every team without a membership row, lends a
+/// team a token that keeps working — sweep included — until the role is taken away.
+///
+[Trait("Category", "E2E")]
+[Trait("Surface", "Http")]
+public sealed class DepartedMemberCredentialE2ETests : IClassFixture
+{
+ private const int MaxSweepPasses = 10;
+
+ /// The departure sweep is deployment-wide: every earlier test's leftover token of someone without standing is a candidate ahead of ours, fifty a pass.
+ private const int MaxDrainPasses = 50;
+
+ private readonly GitProviderApiFactory _factory;
+
+ public DepartedMemberCredentialE2ETests(GitProviderApiFactory factory) { _factory = factory; }
+
+ public enum Departure { Removed, Left }
+
+ [Theory]
+ [InlineData(Departure.Removed)]
+ [InlineData(Departure.Left)]
+ public async Task A_departed_members_token_stops_working_for_the_team_they_left(Departure departure)
+ {
+ var world = await SeedWorldAsync();
+ var identityA = await LinkTokenAsync(world.DepartingId, world.TeamA, world.InstanceA, "departing-token-a");
+ var identityB = await LinkTokenAsync(world.DepartingId, world.TeamB, world.InstanceB, "departing-token-b");
+ var tokenA = await IdentityCredentialAsync(identityA);
+ var title = $"opened as a departed member {Guid.NewGuid():N}";
+
+ await ShouldSucceedAsync(await BindAsync(world.OwnerId, world.TeamA, world.InstanceA, tokenA, "acme/web"), "fixture check: while they are in the team, an Owner can bind through their token");
+ await _factory.JobClient.DrainAsync();
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/credentials", world.MemberId, world.TeamA), "fixture check: list")).ShouldContain(tokenA.ToString());
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, $"/api/repositories/{world.ConnectionRepository}/act-as-candidates", world.MemberId, world.TeamA), "fixture check: picker")).ShouldContain(world.DepartingId.ToString());
+
+ var whileMember = await RunOpenPullRequestAsAsync(world, world.DepartingId, $"{title} while a member");
+ whileMember.Status.ShouldBe(WorkflowRunStatus.Success, $"fixture check: while they are in the team, a workflow acts as them — node said: {whileMember.NodeError}");
+ _factory.PullRequestOpens.All.Select(o => o.Title).ShouldContain($"{title} while a member", "fixture check: the act-as write reached the provider");
+
+ await DepartAsync(world, departure);
+
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/credentials", world.MemberId, world.TeamA), "list")).ShouldNotContain(tokenA.ToString(), Case.Insensitive, $"{departure}: a Member no longer sees the departed member's token");
+
+ var browse = await SendAsync(HttpMethod.Get, $"/api/credentials/{tokenA}/accessible-repositories", world.MemberId, world.TeamA);
+ browse.StatusCode.ShouldBeOneOf(new[] { HttpStatusCode.Forbidden, HttpStatusCode.NotFound }, $"{departure}: a Member can no longer enumerate what the departed member's account reaches");
+
+ var bind = await BindAsync(world.OwnerId, world.TeamA, world.InstanceA, tokenA, "acme/cli");
+ ((int)bind.StatusCode).ShouldBeInRange(400, 499, $"{departure}: an Owner can no longer bind through it — {await bind.Content.ReadAsStringAsync()}");
+
+ var run = await RunOpenPullRequestAsAsync(world, world.DepartingId, title);
+ ShouldBeRefusedForWantOfAnIdentity(run, $"{departure}: a workflow naming the departed member as its author");
+ _factory.PullRequestOpens.All.Select(o => o.Title).ShouldNotContain(title, $"{departure}: the refusal came before any provider call — the departed member's token was never presented");
+
+ await ShouldBeRetiredAsync(tokenA, identityA, departure);
+
+ var theirs = await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/me/identities", world.DepartingId, world.TeamB), "their identities, from the team they are still in");
+ theirs.ShouldContain(identityB.ToString(), Case.Insensitive, $"{departure}: the identity they linked in their own team is untouched");
+ theirs.ShouldNotContain(identityA.ToString(), Case.Insensitive, $"{departure}: nothing of theirs is left live on the team they left");
+ }
+
+ [Theory]
+ [InlineData(Departure.Removed)]
+ [InlineData(Departure.Left)]
+ public async Task A_run_launched_while_its_author_was_a_member_never_writes_through_their_token_after_they_go(Departure departure)
+ {
+ // The run is accepted and queued while the person it acts as still belongs to the team; it executes after they
+ // have gone. Whoever it acts as is decided when the write happens, not when the run was launched, so the queued
+ // act-as node meets the departure and is refused before the provider is asked.
+ var world = await SeedWorldAsync();
+ await LinkTokenAsync(world.DepartingId, world.TeamA, world.InstanceA, "departing-token-a");
+ var title = $"in flight across a departure {Guid.NewGuid():N}";
+
+ var runId = await LaunchOpenPullRequestAsAsync(world, world.DepartingId, title);
+
+ await DepartAsync(world, departure);
+ await _factory.JobClient.DrainAsync();
+
+ ShouldBeRefusedForWantOfAnIdentity(await ReadRunAsync(runId), $"{departure}: the run launched before the departure");
+ _factory.PullRequestOpens.All.Select(o => o.Title).ShouldNotContain(title, $"{departure}: the in-flight run never presented the departed member's token");
+ }
+
+ public enum ConnectPath { Token, OAuth }
+
+ [Theory]
+ [InlineData(ConnectPath.Token)]
+ [InlineData(ConnectPath.OAuth)]
+ public async Task Naming_a_credential_owner_outside_your_reach_is_refused_with_one_answer_for_elsewhere_and_nowhere(ConnectPath path)
+ {
+ var world = await SeedWorldAsync();
+ var elsewhere = await SeedUserInOwnTeamAsync("Victoria Elsewhere");
+
+ var foreign = await StartConnectForAsync(path, world, elsewhere);
+ var nowhere = await StartConnectForAsync(path, world, Guid.NewGuid());
+ var aboveYou = await StartConnectForAsync(path, world, world.OwnerId);
+ var belowYou = await StartConnectForAsync(path, world, world.MemberId);
+
+ foreign.StatusCode.ShouldBe(HttpStatusCode.NotFound, $"{path}: a user of another tenant is not someone this team can name");
+ nowhere.StatusCode.ShouldBe(HttpStatusCode.NotFound, $"{path}: an invented id is refused before the foreign key can turn it into a 500");
+ (await nowhere.Content.ReadAsStringAsync()).ShouldBe(await foreign.Content.ReadAsStringAsync(), $"{path}: elsewhere and nowhere must read the same — otherwise the endpoint tells a real account from an invented one");
+ aboveYou.StatusCode.ShouldBe(HttpStatusCode.Forbidden, $"{path}: an Admin does not act on the Owner, so cannot connect a credential in the Owner's name");
+ await ShouldSucceedAsync(belowYou, $"control: an Admin may start a {path} connect for a Member below them");
+
+ var listed = await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/credentials", world.AdminId, world.TeamA), "list");
+ listed.ShouldNotContain(elsewhere.ToString(), Case.Insensitive, $"{path}: no credential carries the foreign account's id");
+ listed.ShouldNotContain("Victoria Elsewhere", Case.Insensitive, $"{path}: nor discloses the foreign account's display name");
+ (await PendingConnectsForAsync(elsewhere)).ShouldBe(0, $"{path}: no connect waits for a callback in the foreign account's name");
+ }
+
+ [Fact]
+ public async Task Unlinking_an_identity_breaks_the_repositories_bound_through_its_token()
+ {
+ var world = await SeedWorldAsync();
+ var identity = await LinkTokenAsync(world.DepartingId, world.TeamA, world.InstanceA, "token-to-unlink");
+ var token = await IdentityCredentialAsync(identity);
+ var bound = JsonDocument.Parse(await ShouldSucceedAsync(await BindAsync(world.OwnerId, world.TeamA, world.InstanceA, token, "acme/web"), "bind through the token")).RootElement.GetProperty("id").GetGuid();
+ await _factory.JobClient.DrainAsync();
+
+ var unlink = await SendAsync(HttpMethod.Delete, $"/api/me/identities/{identity}", world.DepartingId, world.TeamA);
+ unlink.StatusCode.ShouldBe(HttpStatusCode.NoContent);
+
+ var repo = await LoadRepositoryAsync(bound);
+ repo.Status.ShouldBe(RepositoryStatus.Error, "a repository bound through an unlinked token says it needs a new credential instead of failing at its next run");
+ repo.LastError.ShouldNotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public async Task A_token_added_without_naming_an_owner_goes_when_whoever_added_it_leaves()
+ {
+ var world = await SeedWorldAsync();
+
+ await ShouldSucceedAsync(await SendAsync(HttpMethod.Patch, $"/api/teams/members/{world.DepartingId}", world.OwnerId, world.TeamA, new { role = "Admin" }), "fixture: the Owner makes them an Admin, who may add tokens");
+
+ var added = await ShouldSucceedAsync(await SendAsync(HttpMethod.Post, "/api/credentials/pat", world.DepartingId, world.TeamA, new { providerInstanceId = world.InstanceA, displayName = "my token", token = "glpat-mine" }), "add a token without naming an owner");
+ var tokenId = JsonDocument.Parse(added).RootElement.GetProperty("id").GetGuid();
+
+ var listed = await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/credentials", world.MemberId, world.TeamA), "fixture check: list");
+ listed.ShouldContain(tokenId.ToString(), Case.Insensitive, "fixture check: while they are in the team, the token is one of its credentials");
+ listed.ShouldContain(world.DepartingId.ToString(), Case.Insensitive, "the token is listed as theirs — a personal token added without naming an owner is the caller's own");
+
+ await DepartAsync(world, Departure.Left);
+
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/credentials", world.MemberId, world.TeamA), "list")).ShouldNotContain(tokenId.ToString(), Case.Insensitive, "a Member no longer sees the token of the person who added it and left");
+
+ var bind = await BindAsync(world.OwnerId, world.TeamA, world.InstanceA, tokenId, "acme/web");
+ ((int)bind.StatusCode).ShouldBeInRange(400, 499, $"an Owner can no longer bind through it — {await bind.Content.ReadAsStringAsync()}");
+
+ var token = await LoadCredentialAsync(tokenId);
+ token.Status.ShouldBe(CredentialStatus.Revoked);
+ token.EncryptedPayload.ShouldBe(string.Empty);
+ token.DeletedDate.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task A_member_who_left_before_departures_retired_tokens_loses_them_once_the_sweep_runs()
+ {
+ // The state every departure before this change left behind, and every departure an older pod serves during the
+ // rolling deploy: the membership row is gone and the token is still the team's.
+ var world = await SeedWorldAsync();
+ var identityA = await LinkTokenAsync(world.DepartingId, world.TeamA, world.InstanceA, "departing-token-a");
+ var identityB = await LinkTokenAsync(world.DepartingId, world.TeamB, world.InstanceB, "departing-token-b");
+ var tokenA = await IdentityCredentialAsync(identityA);
+ var tokenB = await IdentityCredentialAsync(identityB);
+
+ await ShouldSucceedAsync(await BindAsync(world.OwnerId, world.TeamA, world.InstanceA, tokenA, "acme/web"), "fixture: an Owner binds through their token while they are in the team");
+ await _factory.JobClient.DrainAsync();
+
+ await DeleteMembershipAsDeparturesUsedToAsync(world.TeamA, world.DepartingId);
+
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/credentials", world.MemberId, world.TeamA), "fixture check: list")).ShouldContain(tokenA.ToString(), Case.Insensitive, "fixture check: a departure from before left the token listed");
+
+ await SweepDepartedUntilRetiredAsync(tokenA);
+
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/credentials", world.MemberId, world.TeamA), "list")).ShouldNotContain(tokenA.ToString(), Case.Insensitive, "a Member no longer sees the departed member's token");
+
+ var browse = await SendAsync(HttpMethod.Get, $"/api/credentials/{tokenA}/accessible-repositories", world.MemberId, world.TeamA);
+ browse.StatusCode.ShouldBeOneOf(new[] { HttpStatusCode.Forbidden, HttpStatusCode.NotFound }, "a Member can no longer enumerate what the departed member's account reaches");
+
+ var bind = await BindAsync(world.OwnerId, world.TeamA, world.InstanceA, tokenA, "acme/cli");
+ ((int)bind.StatusCode).ShouldBeInRange(400, 499, $"an Owner can no longer bind through it — {await bind.Content.ReadAsStringAsync()}");
+
+ (await LoadRepositoryAsync(await RepositoryBoundAsync(tokenA, "acme/web"))).Status.ShouldBe(RepositoryStatus.Error, "the repository bound through it says it needs a new credential");
+
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/credentials", world.DepartingId, world.TeamB), "their own team's list")).ShouldContain(tokenB.ToString(), Case.Insensitive, "the token they lent the team they are still in is untouched");
+ (await LoadCredentialAsync(tokenB)).Status.ShouldBe(CredentialStatus.Active);
+ }
+
+ [Fact]
+ public async Task An_instance_admin_outside_the_team_lends_it_a_token_that_stays_until_they_lose_the_role()
+ {
+ // A live instance Admin stands in every team as an Owner does — the pipeline lets them in without a membership row
+ // — and lends a team a token without joining it. That must keep working: connecting, binding through the token, a
+ // workflow acting as them, and the departure sweep leaving it alone on deploy. Once the role is taken away (by an
+ // operator: nothing in the app writes role_user) they stand nowhere, and the sweep retires what they lent.
+ var world = await SeedWorldAsync();
+ var adminId = await SeedInstanceAdminAsync();
+ var identity = await LinkTokenAsync(adminId, world.TeamA, world.InstanceA, "admins-token");
+ var token = await IdentityCredentialAsync(identity);
+ var title = $"opened as an instance admin {Guid.NewGuid():N}";
+
+ var added = await ShouldSucceedAsync(await SendAsync(HttpMethod.Post, "/api/credentials/pat", adminId, world.TeamA, new { providerInstanceId = world.InstanceA, displayName = "admin's token", token = "glpat-admin" }), "the instance Admin adds a token without naming an owner");
+ var addedId = JsonDocument.Parse(added).RootElement.GetProperty("id").GetGuid();
+ await ShouldSucceedAsync(await SendAsync(HttpMethod.Post, "/api/credentials/oauth/init", adminId, world.TeamA, new { providerInstanceId = world.InstanceA, displayName = "admin's connect" }), "the instance Admin starts an OAuth connect of their own");
+ (await PendingConnectsForAsync(adminId)).ShouldBe(1, "the connect waits for its callback in the instance Admin's name");
+
+ await ShouldSucceedAsync(await BindAsync(world.OwnerId, world.TeamA, world.InstanceA, token, "acme/web"), "the Owner binds through the instance Admin's token");
+ await _factory.JobClient.DrainAsync();
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, $"/api/repositories/{world.ConnectionRepository}/act-as-candidates", world.MemberId, world.TeamA), "picker")).ShouldContain(adminId.ToString(), Case.Insensitive, "the picker offers the instance Admin");
+
+ var control = await IdentityCredentialAsync(await LinkTokenAsync(world.DepartingId, world.TeamA, world.InstanceA, "departing-token-a"));
+ await DeleteMembershipAsDeparturesUsedToAsync(world.TeamA, world.DepartingId);
+ await SweepDepartedUntilRetiredAsync(control);
+ await DrainDepartureSweepAsync();
+
+ foreach (var keptId in new[] { token, addedId })
+ (await LoadCredentialAsync(keptId)).Status.ShouldBe(CredentialStatus.Active, "the sweep leaves what an instance Admin lent a team alone — they stand in it");
+
+ (await LoadRepositoryAsync(await RepositoryBoundAsync(token, "acme/web"))).Status.ShouldBe(RepositoryStatus.Active, "the repository bound through the Admin's token keeps working across the sweep");
+
+ var asAdmin = await RunOpenPullRequestAsAsync(world, adminId, title);
+ asAdmin.Status.ShouldBe(WorkflowRunStatus.Success, $"a Member's workflow acts as the instance Admin — node said: {asAdmin.NodeError}");
+ _factory.PullRequestOpens.All.Select(o => o.Title).ShouldContain(title, "the act-as write reached the provider through the Admin's token");
+
+ await RevokeInstanceAdminAsync(adminId);
+ await SweepDepartedUntilRetiredAsync(token);
+ await SweepDepartedUntilRetiredAsync(addedId);
+
+ (await LoadRepositoryAsync(await RepositoryBoundAsync(token, "acme/web"))).Status.ShouldBe(RepositoryStatus.Error, "once the role is gone, the repository bound through their token says it needs a new credential");
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, "/api/credentials", world.MemberId, world.TeamA), "list")).ShouldNotContain(token.ToString(), Case.Insensitive, "a Member no longer sees the ex-Admin's token");
+ (await ShouldSucceedAsync(await SendAsync(HttpMethod.Get, $"/api/repositories/{world.ConnectionRepository}/act-as-candidates", world.MemberId, world.TeamA), "picker")).ShouldNotContain(adminId.ToString(), Case.Insensitive, "the picker no longer offers them");
+
+ var afterDemotion = await RunOpenPullRequestAsAsync(world, adminId, $"{title} after the role went");
+ ShouldBeRefusedForWantOfAnIdentity(afterDemotion, "a workflow naming the ex-Admin as its author");
+ }
+
+ // ── Flow steps ────────────────────────────────────────────────────────────────
+
+ /// What departures did before they retired anything: the membership row goes, and nothing else does.
+ private async Task DeleteMembershipAsDeparturesUsedToAsync(Guid teamId, Guid userId)
+ {
+ using var scope = _factory.Services.CreateScope();
+
+ await scope.ServiceProvider.GetRequiredService().TeamMembership.Where(m => m.TeamId == teamId && m.UserId == userId).ExecuteDeleteAsync();
+ }
+
+ ///
+ /// The departure sweep's recurring command until a pass retires nothing, so every candidate it has has been retired —
+ /// for asserting a token is NOT one.
+ ///
+ private async Task DrainDepartureSweepAsync()
+ {
+ for (var pass = 1; pass <= MaxDrainPasses; pass++)
+ {
+ using var scope = _factory.Services.CreateScope();
+
+ if (await scope.ServiceProvider.GetRequiredService().Send(new RetireDepartedMembersCredentialsCommand()) == 0) return;
+ }
+
+ throw new ShouldAssertException($"RetireDepartedMembersCredentialsCommand still retired tokens after {MaxDrainPasses} passes — check that a retired token stops being a candidate.");
+ }
+
+ /// An account that signs in, holding the instance Admin role through a role_user row, and no membership anywhere.
+ private async Task SeedInstanceAdminAsync()
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var admin = NewUser("instance-admin", Guid.NewGuid().ToString("N")[..8]);
+
+ db.User.Add(admin);
+ db.RoleUser.Add(new RoleUser { Id = Guid.NewGuid(), RoleId = await db.Role.Where(r => r.Name == Roles.Admin).Select(r => r.Id).SingleAsync(), UserId = admin.Id });
+ await db.SaveChangesAsync();
+
+ return admin.Id;
+ }
+
+ /// How the role is taken away: nothing in the app writes role_user, so an operator deletes the row.
+ private async Task RevokeInstanceAdminAsync(Guid userId)
+ {
+ using var scope = _factory.Services.CreateScope();
+
+ await scope.ServiceProvider.GetRequiredService().RoleUser.Where(r => r.UserId == userId).ExecuteDeleteAsync();
+ }
+
+ /// The departure sweep's recurring command through the app's own mediator, until the token is retired.
+ private async Task SweepDepartedUntilRetiredAsync(Guid credentialId)
+ {
+ for (var pass = 1; pass <= MaxSweepPasses; pass++)
+ {
+ using (var scope = _factory.Services.CreateScope())
+ await scope.ServiceProvider.GetRequiredService().Send(new RetireDepartedMembersCredentialsCommand());
+
+ if ((await LoadCredentialAsync(credentialId)).DeletedDate != null) return;
+ }
+
+ throw new ShouldAssertException($"RetireDepartedMembersCredentialsCommand had not retired credential {credentialId} after {MaxSweepPasses} passes — check that its candidate query finds a live Personal token whose owner holds no membership in its team.");
+ }
+
+ private async Task LinkTokenAsync(Guid userId, Guid teamId, Guid instanceId, string token)
+ {
+ var body = await ShouldSucceedAsync(await SendAsync(HttpMethod.Post, "/api/me/identities/pat", userId, teamId, new { providerInstanceId = instanceId, accessToken = token }), "link a token");
+
+ return JsonDocument.Parse(body).RootElement.GetProperty("id").GetGuid();
+ }
+
+ private Task BindAsync(Guid userId, Guid teamId, Guid instanceId, Guid credentialId, string project) =>
+ SendAsync(HttpMethod.Post, "/api/repositories/bind", userId, teamId, new { providerInstanceId = instanceId, credentialId, projectIdentifier = project });
+
+ /// The Admin starts a connect in 's name: a token added outright, or an OAuth connect begun and left waiting for its callback.
+ private Task StartConnectForAsync(ConnectPath path, World world, Guid ownerUserId) => path == ConnectPath.Token
+ ? SendAsync(HttpMethod.Post, "/api/credentials/pat", world.AdminId, world.TeamA, new { providerInstanceId = world.InstanceA, ownerUserId, displayName = "named token", token = "glpat-named" })
+ : SendAsync(HttpMethod.Post, "/api/credentials/oauth/init", world.AdminId, world.TeamA, new { providerInstanceId = world.InstanceA, displayName = "named connect", intendedOwnerUserId = ownerUserId });
+
+ private async Task DepartAsync(World world, Departure departure)
+ {
+ var departed = departure == Departure.Removed
+ ? await SendAsync(HttpMethod.Delete, $"/api/teams/members/{world.DepartingId}", world.OwnerId, world.TeamA)
+ : await SendAsync(HttpMethod.Post, "/api/teams/members/leave", world.DepartingId, world.TeamA);
+
+ departed.StatusCode.ShouldBe(HttpStatusCode.NoContent, $"{departure}: {await departed.Content.ReadAsStringAsync()}");
+ }
+
+ /// A Member authors a one-node workflow whose git.open_pr acts as , runs it, and drains the engine.
+ private async Task RunOpenPullRequestAsAsync(World world, Guid actAsUserId, string title)
+ {
+ var runId = await LaunchOpenPullRequestAsAsync(world, actAsUserId, title);
+
+ await _factory.JobClient.DrainAsync();
+
+ return await ReadRunAsync(runId);
+ }
+
+ /// A Member authors and launches the workflow; the run is accepted and queued, and nothing executes until the engine is drained.
+ private async Task LaunchOpenPullRequestAsAsync(World world, Guid actAsUserId, string title)
+ {
+ var definition = new
+ {
+ name = $"act-as-{Guid.NewGuid():N}"[..24],
+ definition = new
+ {
+ schemaVersion = 1,
+ nodes = new object[]
+ {
+ new { id = "start", typeKey = "trigger.manual", config = new { }, inputs = new { } },
+ new { id = "act", typeKey = "git.open_pr", config = new { }, inputs = new Dictionary { ["repositoryId"] = world.ConnectionRepository.ToString(), ["title"] = title, ["sourceBranch"] = "feature", ["targetBranch"] = "main", ["actAsUserId"] = actAsUserId.ToString() } },
+ new { id = "end", typeKey = "builtin.terminal", config = new { }, inputs = new { } },
+ },
+ edges = new object[] { new { from = "start", to = "act" }, new { from = "act", to = "end" } },
+ },
+ activations = Array.Empty