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
43 changes: 38 additions & 5 deletions backend/src/CodeSpace.Api/Controllers/CredentialsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using CodeSpace.Messages.Queries.Credentials;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace CodeSpace.Api.Controllers;

Expand Down Expand Up @@ -89,17 +90,49 @@ public async Task<IActionResult> AddGroupAccessToken([FromBody] AddGroupAccessTo
return Ok(new { id });
}

/// <summary>
/// Disconnect. The body is optional — a client that sends none behaves as it always has — and may name where the
/// credential's repositories and group hooks go (<c>moveBindingsToCredentialId</c>). Route's credentialId is
/// authoritative: merged into the command with <c>with</c> so a body that disagrees cannot redirect the revoke.
/// </summary>
[HttpPost("{credentialId:guid}/revoke")]
public async Task<IActionResult> Revoke([FromRoute] Guid credentialId, CancellationToken cancellationToken)
public async Task<IActionResult> Revoke([FromRoute] Guid credentialId, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] RevokeCredentialCommand? command, CancellationToken cancellationToken)
{
var result = await _mediator.Send((command ?? new RevokeCredentialCommand()) with { 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>.
/// </summary>
[HttpPost("{fromCredentialId:guid}/relink-bindings")]
public async Task<IActionResult> RelinkBindings([FromRoute] Guid fromCredentialId, [FromBody] RelinkCredentialBindingsCommand command, CancellationToken cancellationToken)
{
var result = await _mediator.Send(command with { FromCredentialId = fromCredentialId }, cancellationToken).ConfigureAwait(false);
return Ok(result);
}

/// <summary>What leans on the credential — repositories with their projects, group hooks, act-as identities, and the workflow triggers naming those repositories. Live or dead.</summary>
[HttpGet("{credentialId:guid}/dependents")]
public async Task<IActionResult> Dependents([FromRoute] Guid credentialId, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new GetCredentialDependentsQuery { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false);
return Ok(result);
}

/// <summary>The credentials the caller could move this one's bindings onto, best first: their own live connections, then the team's service tokens.</summary>
[HttpGet("{credentialId:guid}/relink-suggestions")]
public async Task<IActionResult> RelinkSuggestions([FromRoute] Guid credentialId, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new RevokeCredentialCommand { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false);
var result = await _mediator.Send(new GetRelinkSuggestionsQuery { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false);
return Ok(result);
}

/// <summary>
/// Pre-revoke preview: how many repositories will be marked Error if this credential
/// gets disconnected. UI uses this to put the right number in the confirm dialog so
/// the user isn't surprised by a silent cascade.
/// Pre-revoke preview: what a Disconnect of this credential, asked by the caller right now, will do — how many
/// repositories will be marked Error, or how many will instead move to the person's one other live connection.
/// UI uses this to put the right number in the confirm dialog so the user isn't surprised by a silent cascade.
/// </summary>
[HttpGet("{credentialId:guid}/usage")]
public async Task<IActionResult> Usage([FromRoute] Guid credentialId, CancellationToken cancellationToken)
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 RelinkCredentialBindingsCommandHandler : IRequestHandler<RelinkCredentialBindingsCommand, RelinkCredentialBindingsResult>
{
private readonly ICredentialRelinkService _service;

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

public async Task<RelinkCredentialBindingsResult> Handle(RelinkCredentialBindingsCommand request, CancellationToken cancellationToken) =>
await _service.RelinkAllAsync(request.FromCredentialId, request.ToCredentialId, cancellationToken).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ public sealed class RevokeCredentialCommandHandler : IRequestHandler<RevokeCrede
public RevokeCredentialCommandHandler(ICredentialService service) { _service = service; }

public async Task<RevokeCredentialResult> Handle(RevokeCredentialCommand request, CancellationToken cancellationToken) =>
await _service.RevokeAsync(request.CredentialId, cancellationToken).ConfigureAwait(false);
await _service.RevokeAsync(request.CredentialId, request.MoveBindingsToCredentialId, cancellationToken).ConfigureAwait(false);
}
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 GetCredentialDependentsQueryHandler : IRequestHandler<GetCredentialDependentsQuery, CredentialDependents>
{
private readonly ICredentialRelinkService _service;

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

public async Task<CredentialDependents> Handle(GetCredentialDependentsQuery request, CancellationToken cancellationToken) =>
await _service.GetDependentsAsync(request.CredentialId, cancellationToken).ConfigureAwait(false);
}
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 GetRelinkSuggestionsQueryHandler : IRequestHandler<GetRelinkSuggestionsQuery, IReadOnlyList<RelinkSuggestion>>
{
private readonly ICredentialRelinkService _service;

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

public async Task<IReadOnlyList<RelinkSuggestion>> Handle(GetRelinkSuggestionsQuery request, CancellationToken cancellationToken) =>
await _service.GetSuggestionsAsync(request.CredentialId, cancellationToken).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using CodeSpace.Core.Persistence.Entities;

namespace CodeSpace.Core.Services.Credentials;

public sealed partial class CredentialRelinkService
{
/// <summary>What one relink-all has established so far; each pipeline step reads and fills it.</summary>
private sealed class RelinkContext
{
public Credential From { get; set; } = default!;
public Credential To { get; set; } = default!;
public List<BoundRepository> Repositories { get; set; } = new();
}

private sealed record BoundRepository(string ExternalId, string FullPath);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System.Text.Json;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Workflows.RunSources.Matchers;
using CodeSpace.Messages.Dtos.Credentials;
using CodeSpace.Messages.Enums;
using Microsoft.EntityFrameworkCore;

namespace CodeSpace.Core.Services.Credentials;

/// <summary>What leans on a credential, read the way a move would carry it: the same team, the same provider instance, in service.</summary>
public sealed partial class CredentialRelinkService
{
public async Task<CredentialDependents> GetDependentsAsync(Guid credentialId, CancellationToken cancellationToken)
{
var credential = await LoadTeamCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false);
var repositories = await LoadDependentRepositoriesAsync(credential, cancellationToken).ConfigureAwait(false);

return new CredentialDependents
{
CredentialId = credential.Id,
DisplayName = credential.DisplayName,
Status = credential.Status,
Retired = credential.DeletedDate != null,
OwnerUserId = credential.OwnerUserId,
ProviderInstanceId = credential.ProviderInstanceId,
Repositories = repositories,
ConnectionWebhooks = await LoadDependentConnectionWebhooksAsync(credential, cancellationToken).ConfigureAwait(false),
Identities = await LoadDependentIdentitiesAsync(credential, cancellationToken).ConfigureAwait(false),
Activations = await LoadDependentActivationsAsync(credential, repositories, cancellationToken).ConfigureAwait(false)
};
}

private async Task<List<DependentRepository>> LoadDependentRepositoriesAsync(Credential credential, CancellationToken cancellationToken)
{
var rows = await _db.Repository.AsNoTracking()
.Where(r => r.CredentialId == credential.Id && r.TeamId == credential.TeamId && r.ProviderInstanceId == credential.ProviderInstanceId && r.DeletedDate == null)
.OrderBy(r => r.FullPath)
.Select(r => new { r.Id, r.FullPath, r.Status, r.LastError })
.ToListAsync(cancellationToken).ConfigureAwait(false);

var projects = await LoadProjectsByRepositoryAsync(rows.Select(r => r.Id).ToList(), cancellationToken).ConfigureAwait(false);

return rows.Select(r => new DependentRepository { Id = r.Id, FullPath = r.FullPath, Status = r.Status, LastError = r.LastError, Projects = projects[r.Id] }).ToList();
}

private async Task<Dictionary<Guid, List<DependentProject>>> LoadProjectsByRepositoryAsync(List<Guid> repositoryIds, CancellationToken cancellationToken)
{
var links = await _db.ProjectRepository.AsNoTracking()
.Where(l => repositoryIds.Contains(l.RepositoryId) && l.DeletedDate == null && l.Project.DeletedDate == null)
.OrderBy(l => l.Project.Name)
.Select(l => new { l.RepositoryId, l.Project.Id, l.Project.Name, l.Project.Slug })
.ToListAsync(cancellationToken).ConfigureAwait(false);

var byRepository = repositoryIds.ToDictionary(id => id, _ => new List<DependentProject>());

foreach (var link in links) byRepository[link.RepositoryId].Add(new DependentProject { Id = link.Id, Name = link.Name, Slug = link.Slug });

return byRepository;
}

private async Task<List<DependentConnectionWebhook>> LoadDependentConnectionWebhooksAsync(Credential credential, CancellationToken cancellationToken) =>
await _db.ConnectionWebhook.AsNoTracking()
.Where(w => w.CredentialId == credential.Id && w.ProviderInstanceId == credential.ProviderInstanceId && WebhookRegistrationLifecycle.InService.Contains(w.RegistrationStatus))
.OrderBy(w => w.OwnerPath)
.Select(w => new DependentConnectionWebhook { Id = w.Id, OwnerPath = w.OwnerPath, RegistrationStatus = w.RegistrationStatus })
.ToListAsync(cancellationToken).ConfigureAwait(false);

private async Task<List<DependentIdentity>> LoadDependentIdentitiesAsync(Credential credential, CancellationToken cancellationToken)
{
var rows = await _db.UserProviderIdentity.AsNoTracking()
.Where(i => i.CredentialId == credential.Id && i.DeletedDate == null)
.OrderBy(i => i.ProviderUsername)
.Select(i => new { i.Id, i.UserId, i.ProviderUsername })
.ToListAsync(cancellationToken).ConfigureAwait(false);

var userIds = rows.Select(r => r.UserId).ToList();
var names = await _db.User.IgnoreQueryFilters().AsNoTracking().Where(u => userIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id, u => u.Name, cancellationToken).ConfigureAwait(false);

return rows.Select(r => new DependentIdentity { Id = r.Id, UserId = r.UserId, UserName = names.GetValueOrDefault(r.UserId), ProviderUsername = r.ProviderUsername }).ToList();
}

/// <summary>
/// The team's live triggers that name any of the repositories. Read from the activation's own config the way the
/// matchers read it (<see cref="ActivationRepositoryReferences"/>), so a shape they learn is learned here: a trigger
/// that names a stranded repository fires on nothing until the repository is bound again.
/// </summary>
private async Task<List<DependentActivation>> LoadDependentActivationsAsync(Credential credential, List<DependentRepository> repositories, CancellationToken cancellationToken)
{
if (repositories.Count == 0) return new List<DependentActivation>();

var rows = await _db.WorkflowActivation.AsNoTracking()
.Where(a => a.DeletedDate == null && a.Workflow.TeamId == credential.TeamId && a.Workflow.DeletedDate == null)
.OrderBy(a => a.Workflow.Name).ThenBy(a => a.TypeKey)
.Select(a => new { a.Id, a.WorkflowId, WorkflowName = a.Workflow.Name, a.TypeKey, a.Enabled, a.ConfigJson })
.ToListAsync(cancellationToken).ConfigureAwait(false);

var bound = repositories.Select(r => r.Id).ToHashSet();

return rows
.Select(a => new { Row = a, Named = NamedAmong(a.ConfigJson, bound) })
.Where(a => a.Named.Count > 0)
.Select(a => new DependentActivation { ActivationId = a.Row.Id, WorkflowId = a.Row.WorkflowId, WorkflowName = a.Row.WorkflowName, TypeKey = a.Row.TypeKey, Enabled = a.Row.Enabled, RepositoryIds = a.Named })
.ToList();
}

/// <summary>The bound repositories an activation config names. A config that is not JSON names none.</summary>
private static List<Guid> NamedAmong(string configJson, HashSet<Guid> bound)
{
try
{
using var document = JsonDocument.Parse(configJson);

return ActivationRepositoryReferences.Read(document.RootElement).Where(bound.Contains).ToList();
}
catch (JsonException)
{
return new List<Guid>();
}
}
}
Loading
Loading