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,10 +1,6 @@
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Constants;
using CodeSpace.Messages.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace CodeSpace.Core.Authorization;

Expand All @@ -15,44 +11,14 @@ namespace CodeSpace.Core.Authorization;
/// </summary>
public sealed class CredentialManagementAuthorizationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequireCredentialManagement
{
private readonly CodeSpaceDbContext _db;
private readonly ICurrentTeam _currentTeam;
private readonly ICurrentUser _currentUser;
private readonly TeamMembershipResolver _resolver;
private readonly CredentialOwnerOrPermission _rule;

public CredentialManagementAuthorizationBehavior(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, TeamMembershipResolver resolver)
{
_db = db;
_currentTeam = currentTeam;
_currentUser = currentUser;
_resolver = resolver;
}
public CredentialManagementAuthorizationBehavior(CredentialOwnerOrPermission rule) { _rule = rule; }

public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (await IsCallersOwnPersonalCredentialAsync(request.CredentialId, cancellationToken).ConfigureAwait(false)) return await next().ConfigureAwait(false);

await EnsureCredentialsManageAsync(cancellationToken).ConfigureAwait(false);
await _rule.EnsureAsync(request.CredentialId, TeamPermissions.CredentialsManage, cancellationToken).ConfigureAwait(false);

return await next().ConfigureAwait(false);
}

private async Task<bool> IsCallersOwnPersonalCredentialAsync(Guid credentialId, CancellationToken cancellationToken)
{
if (_currentUser.Id is not { } callerId) return false;

return await _db.Credential.AsNoTracking()
.AnyAsync(c => c.Id == credentialId && c.DeletedDate == null && c.Ownership == CredentialOwnership.Personal && c.OwnerUserId == callerId, cancellationToken)
.ConfigureAwait(false);
}

private async Task EnsureCredentialsManageAsync(CancellationToken cancellationToken)
{
var teamId = _currentTeam.Id ?? throw TenantAccessDeniedException.NoTeamSelected(_currentUser.Id, HeaderCurrentTeam.HeaderName);

var role = await _resolver.ResolveRoleAsync(teamId, cancellationToken).ConfigureAwait(false);

if (!TeamPermissionMatrix.IsGranted(role, TeamPermissions.CredentialsManage))
throw TenantAccessDeniedException.MissingTeamPermission(_currentUser.Id, teamId, role, TeamPermissions.CredentialsManage);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Enums;
using Microsoft.EntityFrameworkCore;

namespace CodeSpace.Core.Authorization;

/// <summary>
/// The rule every credential tier inside <see cref="CredentialAccessAuthorizationBehavior{TRequest,TResponse}"/> applies,
/// with its own permission: the owner of a Personal credential passes on the membership that behavior already proved;
/// anyone else — on a teammate's credential, or on the team's own, which has no owner — needs the named team permission.
/// An instance Admin resolves as an Owner (<see cref="TeamMembershipResolver"/>), the standing <see cref="TeamStanding"/>
/// gives them in every team.
/// </summary>
public sealed class CredentialOwnerOrPermission : IScopedDependency
{
private readonly CodeSpaceDbContext _db;
private readonly ICurrentTeam _currentTeam;
private readonly ICurrentUser _currentUser;
private readonly TeamMembershipResolver _resolver;

public CredentialOwnerOrPermission(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, TeamMembershipResolver resolver)
{
_db = db;
_currentTeam = currentTeam;
_currentUser = currentUser;
_resolver = resolver;
}

public async Task EnsureAsync(Guid credentialId, string permission, CancellationToken cancellationToken)
{
if (await IsCallersOwnPersonalCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false)) return;

await EnsurePermissionAsync(permission, cancellationToken).ConfigureAwait(false);
}

private async Task<bool> IsCallersOwnPersonalCredentialAsync(Guid credentialId, CancellationToken cancellationToken)
{
if (_currentUser.Id is not { } callerId) return false;

return await _db.Credential.AsNoTracking()
.AnyAsync(c => c.Id == credentialId && c.DeletedDate == null && c.Ownership == CredentialOwnership.Personal && c.OwnerUserId == callerId, cancellationToken)
.ConfigureAwait(false);
}

private async Task EnsurePermissionAsync(string permission, CancellationToken cancellationToken)
{
var teamId = _currentTeam.Id ?? throw TenantAccessDeniedException.NoTeamSelected(_currentUser.Id, HeaderCurrentTeam.HeaderName);

var role = await _resolver.ResolveRoleAsync(teamId, cancellationToken).ConfigureAwait(false);

if (!TeamPermissionMatrix.IsGranted(role, permission))
throw TenantAccessDeniedException.MissingTeamPermission(_currentUser.Id, teamId, role, permission);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Constants;
using MediatR;

namespace CodeSpace.Core.Authorization;

/// <summary>
/// The owner of a Personal credential may present it to the provider on membership alone; every other caller needs
/// <see cref="TeamPermissions.ReposManage"/>, the bar of the bind such a listing feeds. Runs inside
/// <see cref="CredentialAccessAuthorizationBehavior{TRequest,TResponse}"/>, which has already proven the credential is the
/// header team's and the caller a member of it.
/// </summary>
public sealed class CredentialSpendAuthorizationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequireCredentialSpend
{
private readonly CredentialOwnerOrPermission _rule;

public CredentialSpendAuthorizationBehavior(CredentialOwnerOrPermission rule) { _rule = rule; }

public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
await _rule.EnsureAsync(request.CredentialId, TeamPermissions.ReposManage, cancellationToken).ConfigureAwait(false);

return await next().ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using CodeSpace.Core.Services.Credentials;
using CodeSpace.Messages.Commands.Credentials;
using MediatR;

namespace CodeSpace.Core.Handlers.CommandHandlers.Credentials;

/// <summary>Thin dispatcher (Rule 16) — the production caller of <see cref="IRetiredSecretShredService.ShredAsync"/>.</summary>
public sealed class ShredRetiredSecretsCommandHandler : IRequestHandler<ShredRetiredSecretsCommand, int>
{
private readonly IRetiredSecretShredService _shred;

public ShredRetiredSecretsCommandHandler(IRetiredSecretShredService shred)
{
_shred = shred;
}

public async Task<int> Handle(ShredRetiredSecretsCommand request, CancellationToken cancellationToken)
{
return await _shred.ShredAsync(cancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using CodeSpace.Messages.Commands.Credentials;
using MediatR;

namespace CodeSpace.Core.Jobs.RecurringJobs;

/// <summary>Every hour: blank the secrets a build older than migration 0249 retired without blanking — any an older pod retires during a rolling deploy, and every one retired after a rollback — since 0249's own backfill ran once (thin Rule-14 dispatcher). A blanked row is no longer a candidate, so an idle tick changes nothing.</summary>
public sealed class RetiredSecretShredRecurringJob : IRecurringJob
{
private readonly IMediator _mediator;

public RetiredSecretShredRecurringJob(IMediator mediator) { _mediator = mediator; }

public string JobId => nameof(RetiredSecretShredRecurringJob);
public string CronExpression => "0 * * * *";

public async Task Execute() => await _mediator.Send(new ShredRetiredSecretsCommand()).ConfigureAwait(false);
}
3 changes: 3 additions & 0 deletions backend/src/CodeSpace.Core/Mediation/MediatorModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ protected override void Load(ContainerBuilder builder)
// already vetted, this adds the role check for write/action requests
// CredentialManagement — inside Credential (whose marker it extends): the owner of a Personal
// credential passes on membership, anyone else needs credentials.manage
// CredentialSpend — the same shape for presenting a credential to its provider: the owner of a
// Personal credential passes on membership, anyone else needs repos.manage
// BotVisibility — opts the request into seeing bot users (IBotInclusive); default excludes
// Transactional — innermost of these, wraps DB writes only after auth has passed
//
Expand All @@ -56,6 +58,7 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterGeneric(typeof(RepositoryAccessAuthorizationBehavior<,>)).As(typeof(IPipelineBehavior<,>)).InstancePerLifetimeScope();
builder.RegisterGeneric(typeof(CredentialAccessAuthorizationBehavior<,>)).As(typeof(IPipelineBehavior<,>)).InstancePerLifetimeScope();
builder.RegisterGeneric(typeof(CredentialManagementAuthorizationBehavior<,>)).As(typeof(IPipelineBehavior<,>)).InstancePerLifetimeScope();
builder.RegisterGeneric(typeof(CredentialSpendAuthorizationBehavior<,>)).As(typeof(IPipelineBehavior<,>)).InstancePerLifetimeScope();
builder.RegisterGeneric(typeof(BotVisibilityBehavior<,>)).As(typeof(IPipelineBehavior<,>)).InstancePerLifetimeScope();
builder.RegisterGeneric(typeof(TransactionalBehavior<,>)).As(typeof(IPipelineBehavior<,>)).InstancePerLifetimeScope();

Expand Down
Loading
Loading