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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CodeSpace.Messages.Commands.ProviderInstances;
using CodeSpace.Messages.Queries.Credentials;
using CodeSpace.Messages.Enums;
using CodeSpace.Messages.Queries.ProviderInstances;
using CodeSpace.Messages.Queries.Providers;
Expand All @@ -24,6 +25,18 @@ public async Task<IActionResult> List(CancellationToken cancellationToken)
return Ok(result);
}

/// <summary>
/// The credentials a repository on this connection could be re-linked to, best first — the whole list the Re-link dialog shows: the
/// caller's own live connections and the team's service tokens, on this connection or another one to the same host. Route's id is
/// authoritative; <c>fromCredentialId</c> (the credential being left) is the only query-string field.
/// </summary>
[HttpGet("{providerInstanceId:guid}/relink-candidates")]
public async Task<IActionResult> RelinkCandidates([FromRoute] Guid providerInstanceId, [FromQuery] GetRelinkCandidatesQuery query, CancellationToken cancellationToken)
{
var result = await _mediator.Send(query with { ProviderInstanceId = providerInstanceId }, cancellationToken).ConfigureAwait(false);
return Ok(result);
}

[HttpPost]
public async Task<IActionResult> Add([FromBody] AddProviderInstanceCommand command, CancellationToken cancellationToken)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using CodeSpace.Core.Services.Credentials;
using CodeSpace.Messages.Dtos.Credentials;
using CodeSpace.Messages.Queries.Credentials;
using MediatR;

namespace CodeSpace.Core.Handlers.QueryHandlers.Credentials;

public sealed class GetRelinkCandidatesQueryHandler : IRequestHandler<GetRelinkCandidatesQuery, IReadOnlyList<RelinkSuggestion>>
{
private readonly ICredentialRelinkService _service;

public GetRelinkCandidatesQueryHandler(ICredentialRelinkService service) { _service = service; }

public async Task<IReadOnlyList<RelinkSuggestion>> Handle(GetRelinkCandidatesQuery request, CancellationToken cancellationToken) =>
await _service.GetCandidatesAsync(request.ProviderInstanceId, request.FromCredentialId, cancellationToken).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using CodeSpace.Core.Authorization;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Core.Services.ProviderInstances;
using CodeSpace.Core.Services.Providers.Identity;
using CodeSpace.Messages.Dtos.Credentials;
using CodeSpace.Messages.Dtos.Providers;
using CodeSpace.Messages.Enums;
using Microsoft.EntityFrameworkCore;

namespace CodeSpace.Core.Services.Credentials;

/// <summary>The dialog's read side: what a repository could be pointed at, across every connection of the team to the host it lives on.</summary>
public sealed partial class CredentialRelinkService
{
public async Task<IReadOnlyList<RelinkSuggestion>> GetCandidatesAsync(Guid providerInstanceId, Guid? leavingCredentialId, CancellationToken cancellationToken)
{
var teamId = _currentTeam.Id ?? throw TenantAccessDeniedException.NoTeamSelected(_currentUser.Id, HeaderCurrentTeam.HeaderName);
var callerId = _currentUser.Id ?? throw TenantAccessDeniedException.NotAuthenticated(teamId);

var instance = await LoadTeamInstanceAsync(providerInstanceId, teamId, cancellationToken).ConfigureAwait(false);
var leaving = leavingCredentialId is Guid id ? await LoadTeamCredentialAsync(id, cancellationToken).ConfigureAwait(false) : null;

var reach = await ProviderInstanceHost.InterchangeableIdsAsync(_db, instance, cancellationToken).ConfigureAwait(false);
var bindable = await LoadBindableAsync(teamId, reach, callerId, cancellationToken).ConfigureAwait(false);

var ranked = RelinkSuggestionRanking.RankForRepository(instance.Id, leaving, bindable, callerId, _clock.GetUtcNow());

return await WithStrandedActAsAsync(instance.Id, ranked, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// What a pick on another connection takes from the people who act as themselves: an identity belongs to a connection, so a repository that moves
/// leaves behind everyone whose live identity is on the connection it leaves and not on the one it goes to. Named on each candidate that would, by the
/// act-as reader's own predicate — the people the author picker offers today and would no longer. Nothing is read when no candidate is on another
/// connection, so a team with one connection pays for nothing.
/// </summary>
private async Task<IReadOnlyList<RelinkSuggestion>> WithStrandedActAsAsync(Guid repositoryInstanceId, IReadOnlyList<RelinkSuggestion> ranked, CancellationToken cancellationToken)
{
var elsewhere = ranked.Select(r => r.ProviderInstanceId).Where(id => id != repositoryInstanceId).Distinct().ToList();

if (elsewhere.Count == 0) return ranked;

var here = await _actors.ListCandidatesOnInstanceAsync(repositoryInstanceId, cancellationToken).ConfigureAwait(false);

if (here.Count == 0) return ranked;

var stranded = new Dictionary<Guid, IReadOnlyList<string>>();

foreach (var instanceId in elsewhere) stranded[instanceId] = await NamesNotOnAsync(here, instanceId, cancellationToken).ConfigureAwait(false);

return ranked.Select(r => stranded.TryGetValue(r.ProviderInstanceId, out var names) ? r with { StrandedActAs = names } : r).ToList();
}

/// <summary>The names, among <paramref name="people"/>, of those with no live identity on <paramref name="instanceId"/>.</summary>
private async Task<IReadOnlyList<string>> NamesNotOnAsync(IReadOnlyList<ActAsCandidateSummary> people, Guid instanceId, CancellationToken cancellationToken)
{
var there = (await _actors.ListCandidatesOnInstanceAsync(instanceId, cancellationToken).ConfigureAwait(false)).Select(c => c.UserId).ToHashSet();

return people.Where(p => !there.Contains(p.UserId)).Select(p => p.Name).ToList();
}

/// <summary>The team's connected credentials on those connections that the caller may bind with: their own personal ones, and the team's service tokens. The ranking keeps the ones that can still authenticate (<see cref="RelinkSuggestionRanking.CanAuthenticate"/>).</summary>
private async Task<List<Credential>> LoadBindableAsync(Guid teamId, IReadOnlyList<Guid> instanceIds, Guid callerId, CancellationToken cancellationToken) =>
await _db.Credential.AsNoTracking().Include(c => c.ProviderInstance)
.Where(c => c.TeamId == teamId && instanceIds.Contains(c.ProviderInstanceId) && c.Status == CredentialStatus.Active && c.DeletedDate == null)
.Where(c => (c.Ownership == CredentialOwnership.Personal && c.OwnerUserId == callerId) || c.Ownership == CredentialOwnership.TeamService)
.ToListAsync(cancellationToken).ConfigureAwait(false);

/// <summary>The team's provider connection by id — not found for a missing id and for another team's alike.</summary>
private async Task<ProviderInstance> LoadTeamInstanceAsync(Guid id, Guid teamId, CancellationToken cancellationToken) =>
await _db.ProviderInstance.AsNoTracking().SingleOrDefaultAsync(p => p.Id == id && p.TeamId == teamId && p.DeletedDate == null, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"provider instance {id} not found");
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
using CodeSpace.Core.Services.OAuth;
using CodeSpace.Core.Services.Providers;
using CodeSpace.Core.Services.Providers.Capabilities;
using CodeSpace.Messages.Exceptions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace CodeSpace.Core.Services.Credentials;

Expand Down Expand Up @@ -37,26 +32,33 @@ private async Task LoadBoundRepositoriesAsync(CancellationToken cancellationToke
/// disconnected credential), so the whole move is refused and the unreadable ones are named: unbind them, or pick a
/// credential that reaches them. Sequential and read-only, and bounded (<see cref="MaxRepositoriesVerified"/>); with
/// nothing bound there is nothing to ask. Only a provider that ANSWERED says anything about the target
/// (<see cref="TargetIsRefused"/>) — one that did not ends the check at once with a retryable refusal.
/// (<see cref="IRepositoryReadProbe"/>) — one that did not ends the check at once with a retryable refusal.
///
/// <para>A target on another connection to the same host takes the repository to that connection, so it must also read the repository where CodeSpace
/// has it: the same id at another path is another repository, or the same one renamed since CodeSpace last read it, and either way not something to
/// move a binding across on a guess.</para>
/// </summary>
private async Task EnsureTargetReadsEveryRepositoryAsync(CancellationToken cancellationToken)
{
if (_ctx.Repositories.Count == 0) return;

EnsureFewEnoughToVerify();

var catalog = _registry.Require<IRepositoryCatalogCapability>(_ctx.To.ProviderInstance.Provider);
var context = new ProviderContext(_ctx.To.ProviderInstance, _ctx.To);
var changesConnection = _ctx.To.ProviderInstanceId != _ctx.From.ProviderInstanceId;
var unreadable = new List<string>();
var elsewhere = new List<string>();

foreach (var repository in _ctx.Repositories)
{
if (!await CanReadAsync(catalog, context, repository, cancellationToken).ConfigureAwait(false)) unreadable.Add(repository.FullPath);
var read = await _probe.ReadAsync(_ctx.To, repository.ExternalId, _ctx.From.DisplayName, cancellationToken).ConfigureAwait(false);

if (!read.Readable) unreadable.Add(repository.FullPath);
else if (changesConnection && !read.IsAt(repository.FullPath)) elsewhere.Add($"{repository.FullPath} (the provider names it {read.FullPath})");
}

if (unreadable.Count == 0) return;
if (unreadable.Count + elsewhere.Count == 0) return;

throw new InvalidOperationException(UnreadableMessage(unreadable));
throw new InvalidOperationException(elsewhere.Count == 0 ? UnreadableMessage(unreadable) : RefusedMessage(unreadable, elsewhere));
}

private void EnsureFewEnoughToVerify()
Expand All @@ -66,38 +68,23 @@ private void EnsureFewEnoughToVerify()
throw new InvalidOperationException($"{_ctx.Repositories.Count} repositories are bound through '{_ctx.From.DisplayName}', and one relink asks the target to read each of them first — at most {MaxRepositoriesVerified} in one action. Nothing was moved. Unbind the repositories that are no longer wanted, or re-link the rest one at a time.");
}

private async Task<bool> CanReadAsync(IRepositoryCatalogCapability catalog, ProviderContext context, BoundRepository repository, CancellationToken cancellationToken)
{
try
{
await catalog.GetByExternalIdAsync(context, repository.ExternalId, cancellationToken).ConfigureAwait(false);
return true;
}
catch (Exception ex) when (TargetIsRefused(ex))
{
_logger.LogWarning(ex, "Credential {CredentialId} could not read repository {RepositoryPath}; the relink from {FromCredentialId} is refused", context.Credential.Id, repository.FullPath, _ctx.From.Id);
return false;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Could not check whether credential {CredentialId} reads repository {RepositoryPath}, because the provider did not answer; the relink from {FromCredentialId} is refused, to be tried again", context.Credential.Id, repository.FullPath, _ctx.From.Id);

throw new RelinkVerificationUnavailableException(_ctx.To.DisplayName, _ctx.From.DisplayName, ex);
}
}

/// <summary>
/// The provider answered, and the answer is about the target: this token is not let in (401, 403 or an insufficient
/// scope), cannot see that project (404), or — for an OAuth connection — the grant itself was refused when its token was
/// refreshed (the provider's answer, or the credential already marked as needing a reconnect for it). Anything else (a 5xx, a 429, a dropped connection, a local rate limit) says nothing about the target.
/// </summary>
private static bool TargetIsRefused(Exception ex) => ex is ProviderInsufficientScopeException or OAuthExchangeException or CredentialNeedsReconnectException or ProviderApiException { StatusCode: 401 or 403 or 404 };

private string UnreadableMessage(List<string> unreadable)
{
var named = string.Join(", ", unreadable.Take(NamedUnreadableLimit));
var more = unreadable.Count > NamedUnreadableLimit ? $" and {unreadable.Count - NamedUnreadableLimit} more" : string.Empty;

return $"Credential '{_ctx.To.DisplayName}' cannot read {unreadable.Count} of the {_ctx.Repositories.Count} repositories bound through '{_ctx.From.DisplayName}': {named}{more}. Nothing was moved. Pick a credential that reaches them, or unbind those repositories first.";
}

/// <summary>A move onto another connection that some repositories cannot make: the ones the target cannot read, and the ones it reads somewhere else.</summary>
private string RefusedMessage(List<string> unreadable, List<string> elsewhere)
{
var refused = unreadable.Count + elsewhere.Count;
var parts = new List<string>();

if (unreadable.Count > 0) parts.Add($"cannot read {string.Join(", ", unreadable.Take(NamedUnreadableLimit))}");
if (elsewhere.Count > 0) parts.Add($"does not find {string.Join(", ", elsewhere.Take(NamedUnreadableLimit))} where CodeSpace has it");

return $"Credential '{_ctx.To.DisplayName}' cannot take {refused} of the {_ctx.Repositories.Count} repositories bound through '{_ctx.From.DisplayName}' over to its connection: it {string.Join(", and ", parts)}. Nothing was moved. Pick a credential that reaches them, or unbind those repositories first.";
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using CodeSpace.Core.Authorization;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.ProviderInstances;
using CodeSpace.Messages.Dtos.Credentials;
using CodeSpace.Messages.Enums;
using Microsoft.EntityFrameworkCore;
Expand All @@ -13,26 +14,29 @@ public async Task<IReadOnlyList<RelinkSuggestion>> GetSuggestionsAsync(Guid cred
var from = await LoadTeamCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false);
var callerId = _currentUser.Id ?? throw TenantAccessDeniedException.NotAuthenticated(from.TeamId);

var candidates = await LoadCandidatesAsync(from, callerId, cancellationToken).ConfigureAwait(false);
var accepted = await KeepWhatTheMoveAcceptsAsync(from, candidates, callerId, cancellationToken).ConfigureAwait(false);
var reach = await ProviderInstanceHost.InterchangeableIdsAsync(_db, from.ProviderInstance, cancellationToken).ConfigureAwait(false);
var reachSet = reach.ToHashSet();

return RelinkSuggestionRanking.Rank(from, accepted, callerId);
var candidates = await LoadCandidatesAsync(from, reach, callerId, cancellationToken).ConfigureAwait(false);
var accepted = await KeepWhatTheMoveAcceptsAsync(from, candidates, callerId, reachSet, cancellationToken).ConfigureAwait(false);

return RelinkSuggestionRanking.Rank(from, accepted, callerId, reachSet);
}

/// <summary>The live credentials of the team and instance that could be on either list: the caller's own, the team's, and the credential's owner's — the last being whom a Disconnect of a live credential may hand its bindings to.</summary>
private async Task<List<Credential>> LoadCandidatesAsync(Credential from, Guid callerId, CancellationToken cancellationToken) =>
await _db.Credential.AsNoTracking()
.Where(c => c.TeamId == from.TeamId && c.ProviderInstanceId == from.ProviderInstanceId && c.Id != from.Id)
/// <summary>The live credentials of the team, on the credential's instance or another connection to the same host, that could be on either list: the caller's own, the team's, and the credential's owner's — the last being whom a Disconnect of a live credential may hand its bindings to.</summary>
private async Task<List<Credential>> LoadCandidatesAsync(Credential from, IReadOnlyList<Guid> reach, Guid callerId, CancellationToken cancellationToken) =>
await _db.Credential.AsNoTracking().Include(c => c.ProviderInstance)
.Where(c => c.TeamId == from.TeamId && reach.Contains(c.ProviderInstanceId) && c.Id != from.Id)
.Where(c => c.Status == CredentialStatus.Active && c.DeletedDate == null)
.Where(c => c.OwnerUserId == callerId || c.OwnerUserId == from.OwnerUserId || c.Ownership == CredentialOwnership.TeamService)
.ToListAsync(cancellationToken).ConfigureAwait(false);

/// <summary>Only the candidates the rules in <see cref="RelinkSuggestionRanking"/> let through, and of those the ones the move itself would not refuse — the holder of a person's credential must still stand in the team.</summary>
private async Task<List<Credential>> KeepWhatTheMoveAcceptsAsync(Credential from, List<Credential> candidates, Guid callerId, CancellationToken cancellationToken)
private async Task<List<Credential>> KeepWhatTheMoveAcceptsAsync(Credential from, List<Credential> candidates, Guid callerId, IReadOnlySet<Guid> reach, CancellationToken cancellationToken)
{
var accepted = new List<Credential>();

foreach (var candidate in candidates.Where(c => RelinkSuggestionRanking.Qualifies(from, c, callerId)))
foreach (var candidate in candidates.Where(c => RelinkSuggestionRanking.Qualifies(from, c, callerId, reach)))
{
if (await _succession.RefusalAsync(from, candidate, cancellationToken).ConfigureAwait(false) is null) accepted.Add(candidate);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Core.Services.Providers.Capabilities;
using CodeSpace.Core.Services.Providers.Identity;
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Commands.Credentials;
using CodeSpace.Messages.Constants;
Expand All @@ -25,19 +25,23 @@ public sealed partial class CredentialRelinkService : ICredentialRelinkService,
private readonly ICurrentUser _currentUser;
private readonly TeamMembershipResolver _membership;
private readonly ICredentialSuccessionService _succession;
private readonly IProviderRegistry _registry;
private readonly IRepositoryReadProbe _probe;
private readonly IActorIdentityResolver _actors;
private readonly TimeProvider _clock;
private readonly ILogger<CredentialRelinkService> _logger;

private RelinkContext _ctx = new();

public CredentialRelinkService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, TeamMembershipResolver membership, ICredentialSuccessionService succession, IProviderRegistry registry, ILogger<CredentialRelinkService> logger)
public CredentialRelinkService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, TeamMembershipResolver membership, ICredentialSuccessionService succession, IRepositoryReadProbe probe, IActorIdentityResolver actors, TimeProvider clock, ILogger<CredentialRelinkService> logger)
{
_db = db;
_currentTeam = currentTeam;
_currentUser = currentUser;
_membership = membership;
_succession = succession;
_registry = registry;
_probe = probe;
_actors = actors;
_clock = clock;
_logger = logger;
}

Expand Down
Loading
Loading