Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/src/CodeSpace.Api/Controllers/CredentialsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ public async Task<IActionResult> ReplaceToken([FromRoute] Guid credentialId, [Fr
return Ok(result);
}

/// <summary>
/// Remove a disconnected credential nothing leans on any more: soft-deleted, so it leaves the list. Refused with 400 for one still connected and for one with
/// repositories, group hooks or act-as identities left on it. A single route value, so the one-field command is built here (Rule 17).
/// </summary>
[HttpDelete("{credentialId:guid}")]
public async Task<IActionResult> RemoveDisconnected([FromRoute] Guid credentialId, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new RemoveDisconnectedCredentialCommand { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false);
return Ok(result);
}

/// <summary>
/// Move every repository and group hook still bound through a dead credential onto another credential in one action.
/// Route's fromCredentialId is authoritative; the body carries <c>toCredentialId</c>.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using CodeSpace.Core.Services.Credentials;
using CodeSpace.Messages.Commands.Credentials;
using MediatR;

namespace CodeSpace.Core.Handlers.CommandHandlers.Credentials;

public sealed class RemoveDisconnectedCredentialCommandHandler : IRequestHandler<RemoveDisconnectedCredentialCommand, RemoveDisconnectedCredentialResult>
{
private readonly ICredentialService _credentials;

public RemoveDisconnectedCredentialCommandHandler(ICredentialService credentials) { _credentials = credentials; }

public async Task<RemoveDisconnectedCredentialResult> Handle(RemoveDisconnectedCredentialCommand request, CancellationToken cancellationToken) =>
new() { CredentialId = await _credentials.RemoveDisconnectedAsync(request.CredentialId, cancellationToken).ConfigureAwait(false) };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Messages.Dtos.Credentials;
using CodeSpace.Messages.Enums;

namespace CodeSpace.Core.Services.Credentials;

/// <summary>
/// When a credential may be taken out of the team's list: it is disconnected, and nothing leans on it. Pure, so the rule and the
/// words a refusal says are pinned without a database — and so the Providers dialog, which folds away the dead rows with nothing on
/// them, and the removal that accepts them read one rule: a credential still in service is a Disconnect's to end, and what leans on
/// a dead one (<see cref="CredentialDependents"/>) is moved or unlinked before the row goes.
/// </summary>
internal static class CredentialRemoval
{
/// <summary>Why the credential may not be removed, in words a person can act on; null when it may.</summary>
public static string? Refusal(Credential credential, CredentialDependents dependents)
{
if (credential.Status != CredentialStatus.Revoked)
return $"Credential '{credential.DisplayName}' is still connected. Disconnect it first — only a disconnected credential with nothing on it is removed.";

var leaning = Leaning(dependents);

return leaning == null ? null : $"Credential '{credential.DisplayName}' still has {leaning} leaning on it. Move or unlink them first.";
}

/// <summary>"2 repositories, 1 group hook and 1 act-as identity" — only what is there; null when nothing is.</summary>
private static string? Leaning(CredentialDependents dependents)
{
var parts = new List<string>();

AddCount(parts, dependents.Repositories.Count, "repository", "repositories");
AddCount(parts, dependents.ConnectionWebhooks.Count, "group hook", "group hooks");
AddCount(parts, dependents.Identities.Count, "act-as identity", "act-as identities");

return parts.Count == 0 ? null : parts.Count == 1 ? parts[0] : $"{string.Join(", ", parts.Take(parts.Count - 1))} and {parts[^1]}";
}

private static void AddCount(List<string> parts, int count, string one, string many)
{
if (count > 0) parts.Add($"{count} {(count == 1 ? one : many)}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using CodeSpace.Messages.Enums;
using Microsoft.EntityFrameworkCore;

namespace CodeSpace.Core.Services.Credentials;

/// <summary>
/// What else leans on each listed credential beside its repositories, counted for the whole list in one read apiece — the rest of
/// the answer <see cref="ICredentialRelinkService.GetDependentsAsync"/> gives for one credential, with the same rules (a hook is in
/// service by <see cref="WebhookRegistrationLifecycle.InService"/>, an identity is live until it is soft-deleted), so the list's
/// <c>DependentCount</c> and a removal's refusal never disagree on whether a dead row is history.
/// </summary>
public sealed partial class CredentialService
{
private async Task<Dictionary<Guid, int>> CountInServiceConnectionWebhooksAsync(List<Guid> credentialIds, CancellationToken cancellationToken) =>
await _db.ConnectionWebhook.AsNoTracking()
.Where(w => credentialIds.Contains(w.CredentialId) && WebhookRegistrationLifecycle.InService.Contains(w.RegistrationStatus))
.GroupBy(w => w.CredentialId)
.Select(g => new { CredentialId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.CredentialId, g => g.Count, cancellationToken).ConfigureAwait(false);

private async Task<Dictionary<Guid, int>> CountLiveIdentitiesAsync(List<Guid> credentialIds, CancellationToken cancellationToken) =>
await _db.UserProviderIdentity.AsNoTracking()
.Where(i => credentialIds.Contains(i.CredentialId) && i.DeletedDate == null)
.GroupBy(i => i.CredentialId)
.Select(g => new { CredentialId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.CredentialId, g => g.Count, cancellationToken).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Messages.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace CodeSpace.Core.Services.Credentials;

/// <summary>
/// Taking a disconnected credential with nothing on it out of the team's list. The row ends as a departed member's retired credential
/// does — soft-deleted, Revoked, its token already blanked by the Disconnect — so no list shows it and nothing can revive it. A write
/// that reads, judges and then writes would lose to a reconnect landing in between, so the judgement is made again by the write itself.
/// </summary>
public sealed partial class CredentialService
{
public async Task<Guid> RemoveDisconnectedAsync(Guid credentialId, CancellationToken cancellationToken)
{
var credential = await LoadRemovableAsync(credentialId, cancellationToken).ConfigureAwait(false);

await EnsureMayBeRemovedAsync(credential, cancellationToken).ConfigureAwait(false);
await SoftDeleteWhileStillDisconnectedAsync(credential, cancellationToken).ConfigureAwait(false);

_logger.LogInformation("Removed disconnected credential {CredentialId} in team {TeamId}: nothing leaned on it", credential.Id, credential.TeamId);

return credential.Id;
}

/// <summary>The team's credential by id. One already removed is no longer anyone's to remove: not found, as it reads everywhere else.</summary>
private async Task<Credential> LoadRemovableAsync(Guid credentialId, CancellationToken cancellationToken)
{
var credential = await LoadTeamCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false);

if (credential.DeletedDate != null)
throw new KeyNotFoundException($"credential {credentialId} not found");

return credential;
}

/// <summary>What leans on it is read the way a move would carry it (<see cref="ICredentialRelinkService.GetDependentsAsync"/>), so a removal and a relink agree on what a credential's dependents are.</summary>
private async Task EnsureMayBeRemovedAsync(Credential credential, CancellationToken cancellationToken)
{
var dependents = await _relink.GetDependentsAsync(credential.Id, cancellationToken).ConfigureAwait(false);

if (CredentialRemoval.Refusal(credential, dependents) is { } refusal)
throw new InvalidOperationException(refusal);
}

/// <summary>
/// The one write, conditional on the row still being disconnected and still there: a reconnect that landed after the checks sets it Active, and this
/// then matches nothing — refused, never an overwrite of the token the person was just told was stored. <c>ExecuteUpdate</c> skips the audit hook, so the
/// modifier is stamped here.
/// </summary>
private async Task SoftDeleteWhileStillDisconnectedAsync(Credential credential, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var by = _currentUser.Id!.Value;

var stillDisconnected = _db.Credential.Where(c => c.Id == credential.Id && c.Status == CredentialStatus.Revoked && c.DeletedDate == null);

var removed = await stillDisconnected.ExecuteUpdateAsync(s => s.SetProperty(c => c.DeletedDate, now).SetProperty(c => c.LastModifiedDate, now).SetProperty(c => c.LastModifiedBy, by), cancellationToken).ConfigureAwait(false);

if (removed == 0)
throw new InvalidOperationException($"Credential '{credential.DisplayName}' was reconnected or changed while it was being removed, so nothing was removed. Look at it again.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ public sealed partial class CredentialService : ICredentialService, IScopedDepen
private readonly ICredentialRevocationService _revocation;
private readonly ICredentialHealthService _health;
private readonly ICredentialReauthorizer _reauthorizer;
private readonly ICredentialRelinkService _relink;
private readonly ITeamMemberService _members;
private readonly TeamMembershipResolver _membership;
private readonly ILogger<CredentialService> _logger;

public CredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, IOAuthGrantRevoker grants, IProviderRegistry registry, IProviderModuleCatalog modules, IScopeChecker scopeChecker, ICredentialSuccessionService succession, ICredentialRevocationService revocation, ICredentialHealthService health, ICredentialReauthorizer reauthorizer, ITeamMemberService members, TeamMembershipResolver membership, ILogger<CredentialService> logger)
public CredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, IOAuthGrantRevoker grants, IProviderRegistry registry, IProviderModuleCatalog modules, IScopeChecker scopeChecker, ICredentialSuccessionService succession, ICredentialRevocationService revocation, ICredentialHealthService health, ICredentialReauthorizer reauthorizer, ICredentialRelinkService relink, ITeamMemberService members, TeamMembershipResolver membership, ILogger<CredentialService> logger)
{
_db = db;
_currentTeam = currentTeam;
Expand All @@ -54,6 +55,7 @@ public CredentialService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurre
_revocation = revocation;
_health = health;
_reauthorizer = reauthorizer;
_relink = relink;
_members = members;
_membership = membership;
_logger = logger;
Expand Down Expand Up @@ -105,13 +107,18 @@ private async Task<IReadOnlyList<CredentialSummary>> WithHealthAsync(List<Creden
var ids = summaries.Select(s => s.Id).ToList();
var left = await DepartedCredentials.Of(_db).AsNoTracking().Where(c => c.TeamId == teamId && ids.Contains(c.Id)).Select(c => c.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
var bound = await CountBoundRepositoriesAsync(teamId, ids, cancellationToken).ConfigureAwait(false);
var hooks = await CountInServiceConnectionWebhooksAsync(ids, cancellationToken).ConfigureAwait(false);
var identities = await CountLiveIdentitiesAsync(ids, cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;

return summaries.Select(s => s with
{
Health = CredentialHealthDerivation.Derive(s.Status, s.AuthType, s.ExpiresDate, left.Contains(s.Id), now),
DaysToExpiry = CredentialHealthDerivation.DaysToExpiry(s.AuthType, s.ExpiresDate, now),
BoundRepositoryCount = bound.GetValueOrDefault(s.Id)
BoundRepositoryCount = bound.GetValueOrDefault(s.Id),
BoundConnectionWebhookCount = hooks.GetValueOrDefault(s.Id),
ActAsIdentityCount = identities.GetValueOrDefault(s.Id),
DependentCount = bound.GetValueOrDefault(s.Id) + hooks.GetValueOrDefault(s.Id) + identities.GetValueOrDefault(s.Id)
}).ToList();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,13 @@ public interface ICredentialService
/// </summary>
Task<ReplaceCredentialTokenResult> ReplaceTokenAsync(Guid credentialId, string token, CancellationToken cancellationToken);

/// <summary>
/// Take a disconnected credential with nothing leaning on it out of the team's list — soft-deleted, as a departed member's retired credential is.
/// Refused, writing nothing, for one still in service and for one with repositories, group hooks or act-as identities left on it
/// (<c>CredentialRemoval</c>), and for one a reconnect revived while this ran. A credential already removed, or of another team, is not found.
/// Returns the credential's id. Tracked changes only — the caller's unit of work commits them.
/// </summary>
Task<Guid> RemoveDisconnectedAsync(Guid credentialId, CancellationToken cancellationToken);

Task<RemoteRepositoryPage> ListAccessibleRepositoriesAsync(Guid credentialId, string? search, int page, int perPage, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Mediation;

namespace CodeSpace.Messages.Commands.Credentials;

/// <summary>
/// Takes a disconnected credential out of the team's list when nothing leans on it any more: a Disconnect leaves the row behind
/// (Revoked, its token blanked), and a person who disconnects and reconnects a few times is left with a pile of them — dead rows
/// that carry no repository, no group hook and no act-as identity, and that the Providers dialog could only list. The row is
/// soft-deleted, exactly the shape a departed member's retired credential has, so it is no longer listed and cannot be revived.
///
/// <para>Refused, and nothing written, for anything that is not exactly that: a credential that is still connected (a Disconnect, which
/// says where its repositories go, is the way to end one), a disconnected credential that still has repositories bound through it, group
/// hooks registered through it or an act-as identity backed by it (they are moved to another credential first, by
/// <c>RelinkCredentialBindingsCommand</c>), and one that was reconnected while the removal was running. A credential that is already
/// removed, or belongs to another team, reads as not found.</para>
///
/// <para><see cref="IRequireCredentialManagement"/>, the bar of every other write on one credential: its own person removes their own
/// Personal credential on membership alone; anyone else — a teammate's or a team-service credential — needs <c>credentials.manage</c>.</para>
/// </summary>
public sealed record RemoveDisconnectedCredentialCommand : ICommand<RemoveDisconnectedCredentialResult>, IRequireCredentialManagement
{
/// <summary>Set by the controller from the route segment (URL is authoritative).</summary>
public Guid CredentialId { get; init; }
}

public sealed record RemoveDisconnectedCredentialResult
{
public required Guid CredentialId { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,17 @@ public sealed record CredentialSummary

/// <summary>How many live repositories are bound through the credential — what a person reconnecting, replacing or disconnecting it needs to know is at stake. Zero from a server that predates it.</summary>
public int BoundRepositoryCount { get; init; }

/// <summary>
/// How many things lean on the credential: the live repositories bound through it, the group hooks registered through it that are still in service, and the act-as identities it backs — the three a Disconnect leaves behind or a
/// removal would strand (<c>GET /credentials/{id}/dependents</c> lists them). A disconnected credential with none is history, which a client may
/// fold away and offer to remove (<c>RemoveDisconnectedCredentialCommand</c>). Null from a server that predates it, which is also the sign that it has no such removal.
/// </summary>
public int? DependentCount { get; init; }

/// <summary>How many of <see cref="DependentCount"/> are group hooks registered through the credential that are still in service — what a dead credential with no repository left on it is still held by, and what <c>relink-bindings</c> moves with the repositories. Zero from a server that predates it.</summary>
public int BoundConnectionWebhookCount { get; init; }

/// <summary>How many of <see cref="DependentCount"/> are live act-as identities the credential backs. <c>relink-bindings</c> does not move them: they end with the credential's own disconnect or are unlinked. Zero from a server that predates it.</summary>
public int ActAsIdentityCount { get; init; }
}
Loading
Loading