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
Expand Up @@ -2,17 +2,26 @@
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Constants;
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.
/// The rule every credential tier applies, with its own permission: the owner of a Personal credential passes on membership
/// alone; 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.
///
/// <para>Defined once, in <see cref="Permits"/>, because every place that asks must agree: this class enforces it for a request
/// (<see cref="CredentialManagementAuthorizationBehavior{TRequest,TResponse}"/>, the spend tier and the OAuth connect's
/// <see cref="CredentialReauthorizationAuthorizationBehavior{TRequest,TResponse}"/>), <c>CallerStanding</c> decides a remedy's
/// buttons — and the credential list's <c>callerMayRepair</c>, which the repository rows read — by it, and the OAuth callback asks it
/// again of whoever started a reconnect — so a button is never shown to a person the endpoint behind it refuses, and a connect is
/// never finished under a permission that was taken back.</para>
///
/// <para>A reconnect is held to one more thing, which is <see cref="PermitsReconnect"/>: the reach over the credential's person.</para>
/// </summary>
public sealed class CredentialOwnerOrPermission : IScopedDependency
{
Expand All @@ -29,29 +38,46 @@ public CredentialOwnerOrPermission(CodeSpaceDbContext db, ICurrentTeam currentTe
_resolver = resolver;
}

public async Task EnsureAsync(Guid credentialId, string permission, CancellationToken cancellationToken)
/// <summary>
/// The rule: a credential's own person passes, and anyone else needs <paramref name="permission"/>. Pure, so the same answer is the
/// one a request gets, a button shows and a callback asks again. <paramref name="ownsThePersonalCredential"/> is the credential
/// being Personal and the person being its owner — a team-service credential has none, so it is never true for one.
/// </summary>
public static bool Permits(TeamRole role, bool ownsThePersonalCredential, string permission) => ownsThePersonalCredential || TeamPermissionMatrix.IsGranted(role, permission);

/// <summary>
/// A reconnect, which is the rule above and one more: it signs in AS the credential's person, so it acts on a person and not only on a
/// credential, and holds to the reach every act on a member keeps (<see cref="TeamRoleRank.Reaches"/>) — an Admin does not reach another
/// Admin or an Owner, nobody reaches an Owner but themselves. <paramref name="ownerStanding"/> is the role the person stands as in the team
/// (<see cref="TeamStanding.RolesActedOnAsync"/>), and null when they have none — they left, or the credential has no person — which nobody
/// can reconnect. What the service refuses with <c>RoleOutranksActorException</c> or <c>KeyNotFoundException</c> after the pipeline has let
/// the request in is this answer, so a button, the service and the OAuth callback all ask it here.
/// </summary>
public static bool PermitsReconnect(TeamRole role, bool ownsThePersonalCredential, TeamRole? ownerStanding) =>
ownerStanding is { } owner && Permits(role, ownsThePersonalCredential, TeamPermissions.CredentialsManage) && TeamRoleRank.Reaches(role, owner, isOwn: ownsThePersonalCredential);

/// <summary>
/// For the caller on this team. <paramref name="credentialId"/> is null for a request that may name no credential — a connect that
/// mints one has no owner to pass, so it is the permission's alone.
/// </summary>
public async Task EnsureAsync(Guid? credentialId, string permission, CancellationToken cancellationToken)
{
if (await IsCallersOwnPersonalCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false)) return;
var teamId = _currentTeam.Id ?? throw TenantAccessDeniedException.NoTeamSelected(_currentUser.Id, HeaderCurrentTeam.HeaderName);

await EnsurePermissionAsync(permission, cancellationToken).ConfigureAwait(false);
var role = await _resolver.ResolveRoleAsync(teamId, cancellationToken).ConfigureAwait(false);
var owns = credentialId is Guid id && await IsCallersOwnPersonalCredentialAsync(id, teamId, cancellationToken).ConfigureAwait(false);

if (!Permits(role, owns, permission))
throw TenantAccessDeniedException.MissingTeamPermission(_currentUser.Id, teamId, role, permission);
}

private async Task<bool> IsCallersOwnPersonalCredentialAsync(Guid credentialId, CancellationToken cancellationToken)
/// <summary>A live credential of the team being asked about: an owner's pass is for a credential of THIS team, whatever else the request proved.</summary>
private async Task<bool> IsCallersOwnPersonalCredentialAsync(Guid credentialId, Guid teamId, 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)
.AnyAsync(c => c.Id == credentialId && c.TeamId == teamId && 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);
}
}
17 changes: 17 additions & 0 deletions backend/src/CodeSpace.Core/Authorization/CredentialPerson.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using CodeSpace.Messages.Enums;

namespace CodeSpace.Core.Authorization;

/// <summary>
/// The person a Personal credential belongs to, as the rules about acting on it see them: whether they are the one asking, and the role they
/// stand as in the team — null when they have none, because they left and the credential has not been retired yet. Built in one place, from the
/// credential's row and <see cref="TeamStanding.RolesActedOnAsync"/>, by everything that says beforehand what a person may do about a
/// credential (the provider remedy and the credential list), so the two cannot read the same row two ways. A credential with no person — a
/// team's own, or a token stored with no owner — has none, and the callers pass null.
/// </summary>
public sealed record CredentialPerson(bool IsCaller, TeamRole? Standing)
{
/// <summary><paramref name="standing"/> is <see cref="TeamStanding.RolesActedOnAsync"/>'s answer for the credential's team; a person it does not name stands as no one.</summary>
public static CredentialPerson? Of(CredentialOwnership ownership, Guid? ownerUserId, Guid? callerId, IReadOnlyDictionary<Guid, TeamRole> standing) =>
ownership == CredentialOwnership.Personal && ownerUserId is { } owner ? new CredentialPerson(owner == callerId, standing.TryGetValue(owner, out var role) ? role : null) : null;
}
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>
/// A connect that names a credential to re-authorize is held to the credential's bar — its owner on membership alone, anyone else
/// <see cref="TeamPermissions.CredentialsManage"/> — and one that names none mints a credential, which is the permission's alone.
/// Runs inside <see cref="TeamMembershipAuthorizationBehavior{TRequest,TResponse}"/>, which has already proven the caller a member of
/// the header team; <see cref="CredentialOwnerOrPermission"/> proves the named credential is that team's.
/// </summary>
public sealed class CredentialReauthorizationAuthorizationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequireCredentialReauthorization
{
private readonly CredentialOwnerOrPermission _rule;

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

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

return await next().ConfigureAwait(false);
}
}
53 changes: 53 additions & 0 deletions backend/src/CodeSpace.Core/Authorization/TeamStanding.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System.Linq.Expressions;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Constants;
using CodeSpace.Messages.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;

Expand Down Expand Up @@ -37,6 +39,57 @@ public static Expression<Func<T, bool>> Of<T>(CodeSpaceDbContext db, Expression<
public static async Task<bool> HeldAsync(CodeSpaceDbContext db, Guid teamId, Guid userId, CancellationToken cancellationToken) =>
await db.User.IgnoreQueryFilters().AsNoTracking().Where(u => u.Id == userId).AnyAsync(Of<User>(db, _ => teamId, u => u.Id), cancellationToken).ConfigureAwait(false);

/// <summary>
/// What that standing amounts to as a role, asked of anyone — what <see cref="TeamMembershipResolver.ResolveRoleAsync"/> answers for the
/// caller, for a request that has no caller to ask it of (the provider's OAuth callback is anonymous): an instance Admin stands as an
/// Owner, anyone else as the role of their membership row in a live team; null when they have neither. Agrees with
/// <see cref="HeldAsync"/>, which differs only in answering whether rather than what — and, for a member, in treating a deleted team as no
/// standing, as the resolver does.
/// </summary>
public static async Task<TeamRole?> RoleHeldAsync(CodeSpaceDbContext db, Guid teamId, Guid userId, CancellationToken cancellationToken)
{
var isInstanceAdmin = await db.RoleUser.AsNoTracking().AnyAsync(ru => ru.UserId == userId && db.Role.Any(r => r.Id == ru.RoleId && r.Name == Roles.Admin && r.Status), cancellationToken).ConfigureAwait(false);

if (isInstanceAdmin) return TeamRole.Owner;

return await db.Team.AsNoTracking()
.Where(t => t.Id == teamId && t.DeletedDate == null)
.SelectMany(t => t.Memberships.Where(m => m.UserId == userId).Select(m => (TeamRole?)m.Role))
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// What each person is to someone ACTING ON them — naming them as a credential's owner, signing in as them — not to someone asking
/// what they may do: the role of their membership row in a live team, else an instance Admin's standing as an Owner; absent when they have
/// neither. It is the target's side of every reach (<see cref="TeamRoleRank.Reaches"/>), which reads a member's row before an instance
/// Admin's role — the opposite of <see cref="RoleHeldAsync"/>, which answers for someone acting and so lets the role win. One definition for
/// the service that enforces the reach (<c>TeamMemberService.EnsureMayActOnAsync</c>), the callback that asks it again and the remedy
/// and the credential list that say, beforehand, whether it would hold — so a button and the endpoint behind it read the person alike.
/// Asked for many people at once because a list of credentials has one owner each.
/// </summary>
public static async Task<IReadOnlyDictionary<Guid, TeamRole>> RolesActedOnAsync(CodeSpaceDbContext db, Guid teamId, IReadOnlyCollection<Guid> userIds, CancellationToken cancellationToken)
{
var roles = new Dictionary<Guid, TeamRole>();

if (userIds.Count == 0 || !await db.Team.AsNoTracking().AnyAsync(t => t.Id == teamId && t.DeletedDate == null, cancellationToken).ConfigureAwait(false)) return roles;

var rows = await db.TeamMembership.AsNoTracking().Where(m => m.TeamId == teamId && userIds.Contains(m.UserId)).Select(m => new { m.UserId, m.Role }).ToListAsync(cancellationToken).ConfigureAwait(false);
var instanceAdmins = await db.RoleUser.AsNoTracking().Where(ru => userIds.Contains(ru.UserId) && db.Role.Any(r => r.Id == ru.RoleId && r.Name == Roles.Admin && r.Status)).Select(ru => ru.UserId).ToListAsync(cancellationToken).ConfigureAwait(false);

foreach (var row in rows) roles[row.UserId] = row.Role;
foreach (var admin in instanceAdmins) roles.TryAdd(admin, TeamRole.Owner);

return roles;
}

/// <summary><see cref="RolesActedOnAsync"/> for one person: null when they have no standing.</summary>
public static async Task<TeamRole?> RoleActedOnAsync(CodeSpaceDbContext db, Guid teamId, Guid userId, CancellationToken cancellationToken)
{
var roles = await RolesActedOnAsync(db, teamId, new[] { userId }, cancellationToken).ConfigureAwait(false);

return roles.TryGetValue(userId, out var role) ? role : null;
}

private static Expression<Func<Guid, Guid, bool>> 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));
Expand Down
4 changes: 4 additions & 0 deletions backend/src/CodeSpace.Core/Mediation/MediatorModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ protected override void Load(ContainerBuilder builder)
// 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
// CredentialReauthorization — inside TeamMembership (whose marker it extends): a connect that names a credential
// is held to the credential's bar (its owner on membership, anyone else
// credentials.manage); one that names none mints a credential and needs credentials.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 @@ -59,6 +62,7 @@ protected override void Load(ContainerBuilder builder)
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(CredentialReauthorizationAuthorizationBehavior<,>)).As(typeof(IPipelineBehavior<,>)).InstancePerLifetimeScope();
builder.RegisterGeneric(typeof(BotVisibilityBehavior<,>)).As(typeof(IPipelineBehavior<,>)).InstancePerLifetimeScope();
builder.RegisterGeneric(typeof(TransactionalBehavior<,>)).As(typeof(IPipelineBehavior<,>)).InstancePerLifetimeScope();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using CodeSpace.Core.Authorization;
using CodeSpace.Core.Services.Credentials.Remedy;
using CodeSpace.Messages.Dtos.Credentials;
using CodeSpace.Messages.Enums;

namespace CodeSpace.Core.Services.Credentials;

/// <summary>What the person listing may do about each credential — the answer a client shows its Reconnect and Replace token buttons by.</summary>
public sealed partial class CredentialService
{
/// <summary>
/// Says, for each listed credential, whether the caller may put it right in place: a sign-in reconnected or a pasted token replaced, by exactly the
/// rules the endpoints behind those buttons apply (<see cref="CallerStanding"/>), so the list never offers what the server would refuse — a
/// teammate's credential to someone without <c>credentials.manage</c>, another Admin's or an Owner's sign-in to an Admin, one whose person left.
/// One read of the standing of every owner on the list, however long it is.
/// </summary>
private async Task<IReadOnlyList<CredentialSummary>> WithCallerRepairAsync(IReadOnlyList<CredentialSummary> summaries, Guid teamId, CancellationToken cancellationToken)
{
var role = await _membership.ResolveRoleAsync(teamId, cancellationToken).ConfigureAwait(false);
var owners = summaries.Where(s => s.Ownership == CredentialOwnership.Personal && s.OwnerUserId != null).Select(s => s.OwnerUserId!.Value).Distinct().ToList();
var standing = await TeamStanding.RolesActedOnAsync(_db, teamId, owners, cancellationToken).ConfigureAwait(false);

return summaries.Select(s => s with { CallerMayRepair = MayRepair(role, s, standing) }).ToList();
}

private bool MayRepair(TeamRole role, CredentialSummary credential, IReadOnlyDictionary<Guid, TeamRole> standing) =>
CallerStanding.Of(role, CredentialPerson.Of(credential.Ownership, credential.OwnerUserId, _currentUser.Id, standing)).MayRepair(CredentialHealthDerivation.IsStaticToken(credential.AuthType));
}
Loading
Loading