feat: add organization domain foundation
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Data;
|
||||
|
||||
public class Organization
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Data;
|
||||
|
||||
public class OrganizationMembership
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public required string Role { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Data;
|
||||
|
||||
public static class OrganizationModelConfiguration
|
||||
{
|
||||
public static ModelBuilder ConfigureOrganizationsModule(this ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Organization>(organization =>
|
||||
{
|
||||
organization.ToTable("Organizations");
|
||||
organization.HasKey(x => x.Id);
|
||||
organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
organization.Property(x => x.Slug).HasMaxLength(128).IsRequired();
|
||||
organization.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
organization.HasIndex(x => x.Slug).IsUnique();
|
||||
organization.HasIndex(x => x.OwnerUserId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<OrganizationMembership>(membership =>
|
||||
{
|
||||
membership.ToTable("OrganizationMemberships");
|
||||
membership.HasKey(x => x.Id);
|
||||
membership.Property(x => x.Role).HasMaxLength(64).IsRequired();
|
||||
membership.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
membership.HasIndex(x => x.OrganizationId);
|
||||
membership.HasIndex(x => x.UserId);
|
||||
membership.HasIndex(x => new { x.OrganizationId, x.UserId }).IsUnique();
|
||||
membership.HasOne<Organization>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.OrganizationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
return modelBuilder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddOrganizationsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddScoped<OrganizationAccessService>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
using Socialize.Api.Modules.Workspaces.Handlers;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
public class GetOrganizationHandler(
|
||||
AppDbContext dbContext,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: EndpointWithoutRequest<OrganizationDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/organizations/{organizationId:guid}");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid organizationId = Route<Guid>("organizationId");
|
||||
|
||||
Organization? organization = await dbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
|
||||
if (organization is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await organizationAccessService.CanAccessOrganizationAsync(User, organizationId, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyCollection<string> currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
|
||||
User,
|
||||
organizationId,
|
||||
ct);
|
||||
|
||||
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
|
||||
IReadOnlyCollection<WorkspaceDto> workspaces = await GetWorkspacesAsync(organizationId, ct);
|
||||
|
||||
await SendOkAsync(
|
||||
OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
currentUserPermissions,
|
||||
members,
|
||||
workspaces),
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<OrganizationMemberDto>> GetMembersAsync(
|
||||
Guid organizationId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rows = await dbContext.OrganizationMemberships
|
||||
.Where(membership => membership.OrganizationId == organizationId)
|
||||
.Join(
|
||||
dbContext.Users,
|
||||
membership => membership.UserId,
|
||||
user => user.Id,
|
||||
(membership, user) => new { Membership = membership, User = user })
|
||||
.OrderBy(row => row.User.Lastname)
|
||||
.ThenBy(row => row.User.Firstname)
|
||||
.ThenBy(row => row.User.Email)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.Select(row => new OrganizationMemberDto(
|
||||
row.User.Id,
|
||||
BuildDisplayName(row.User),
|
||||
row.User.Email ?? string.Empty,
|
||||
row.User.PortraitUrl,
|
||||
row.Membership.Role,
|
||||
OrganizationPermissionRules.GetPermissionsForRole(row.Membership.Role),
|
||||
row.Membership.CreatedAt))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<WorkspaceDto>> GetWorkspacesAsync(
|
||||
Guid organizationId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var workspaces = await dbContext.Workspaces
|
||||
.Where(workspace => workspace.OrganizationId == organizationId)
|
||||
.OrderBy(workspace => workspace.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return workspaces
|
||||
.Select(workspace => WorkspaceDto.FromWorkspace(workspace, []))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string BuildDisplayName(User user)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(user.Alias))
|
||||
{
|
||||
return user.Alias;
|
||||
}
|
||||
|
||||
string fullName = $"{user.Firstname} {user.Lastname}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(fullName))
|
||||
{
|
||||
return fullName;
|
||||
}
|
||||
|
||||
return user.Email ?? user.UserName ?? user.Id.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
public class GetOrganizationsHandler(
|
||||
AppDbContext dbContext,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<OrganizationDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/organizations");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyCollection<Guid> organizationIds = await organizationAccessService.GetAccessibleOrganizationIdsAsync(User, ct);
|
||||
|
||||
List<Organization> organizations = await dbContext.Organizations
|
||||
.Where(organization => organizationIds.Contains(organization.Id))
|
||||
.OrderBy(organization => organization.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<OrganizationDto> response = [];
|
||||
foreach (Organization organization in organizations)
|
||||
{
|
||||
IReadOnlyCollection<string> permissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
|
||||
User,
|
||||
organization.Id,
|
||||
ct);
|
||||
response.Add(OrganizationDto.FromOrganization(organization, permissions));
|
||||
}
|
||||
|
||||
await SendOkAsync(response, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Handlers;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
public record OrganizationMemberDto(
|
||||
Guid UserId,
|
||||
string DisplayName,
|
||||
string Email,
|
||||
string? PortraitUrl,
|
||||
string Role,
|
||||
IReadOnlyCollection<string> Permissions,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record OrganizationDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
Guid OwnerUserId,
|
||||
IReadOnlyCollection<string> CurrentUserPermissions,
|
||||
IReadOnlyCollection<OrganizationMemberDto> Members,
|
||||
IReadOnlyCollection<WorkspaceDto> Workspaces,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public static OrganizationDto FromOrganization(
|
||||
Organization organization,
|
||||
IReadOnlyCollection<string> currentUserPermissions,
|
||||
IReadOnlyCollection<OrganizationMemberDto>? members = null,
|
||||
IReadOnlyCollection<WorkspaceDto>? workspaces = null)
|
||||
{
|
||||
return new OrganizationDto(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
organization.Slug,
|
||||
organization.OwnerUserId,
|
||||
currentUserPermissions,
|
||||
members ?? [],
|
||||
workspaces ?? [],
|
||||
organization.CreatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
public sealed class OrganizationAccessService(
|
||||
AppDbContext dbContext)
|
||||
{
|
||||
public bool IsGlobalManager(ClaimsPrincipal user)
|
||||
{
|
||||
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<Guid>> GetAccessibleOrganizationIdsAsync(
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (IsGlobalManager(user))
|
||||
{
|
||||
return await dbContext.Organizations
|
||||
.Select(organization => organization.Id)
|
||||
.ToArrayAsync(ct);
|
||||
}
|
||||
|
||||
Guid userId = user.GetUserId();
|
||||
|
||||
Guid[] ownedOrganizationIds = await dbContext.Organizations
|
||||
.Where(organization => organization.OwnerUserId == userId)
|
||||
.Select(organization => organization.Id)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
Guid[] memberOrganizationIds = await dbContext.OrganizationMemberships
|
||||
.Where(membership => membership.UserId == userId)
|
||||
.Select(membership => membership.OrganizationId)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
return ownedOrganizationIds
|
||||
.Concat(memberOrganizationIds)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<Guid>> GetAccessibleWorkspaceIdsAsync(
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (IsGlobalManager(user))
|
||||
{
|
||||
return await dbContext.Workspaces
|
||||
.Select(workspace => workspace.Id)
|
||||
.ToArrayAsync(ct);
|
||||
}
|
||||
|
||||
Guid[] directWorkspaceIds = user.GetWorkspaceScopeIds().ToArray();
|
||||
Guid[] organizationWorkspaceIds = await GetInheritedWorkspaceIdsAsync(user, OrganizationPermissions.AccessOwnedWorkspaces, ct);
|
||||
|
||||
return directWorkspaceIds
|
||||
.Concat(organizationWorkspaceIds)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<bool> CanAccessOrganizationAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid organizationId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (IsGlobalManager(user))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Guid userId = user.GetUserId();
|
||||
|
||||
return await dbContext.Organizations.AnyAsync(
|
||||
organization => organization.Id == organizationId && organization.OwnerUserId == userId,
|
||||
ct)
|
||||
|| await dbContext.OrganizationMemberships.AnyAsync(
|
||||
membership => membership.OrganizationId == organizationId && membership.UserId == userId,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<bool> HasOrganizationPermissionAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid organizationId,
|
||||
string permission,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (IsGlobalManager(user))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Guid userId = user.GetUserId();
|
||||
|
||||
bool owner = await dbContext.Organizations.AnyAsync(
|
||||
organization => organization.Id == organizationId && organization.OwnerUserId == userId,
|
||||
ct);
|
||||
if (owner)
|
||||
{
|
||||
return OrganizationPermissionRules.RoleHasPermission(OrganizationRoles.Owner, permission);
|
||||
}
|
||||
|
||||
string[] roles = await dbContext.OrganizationMemberships
|
||||
.Where(membership => membership.OrganizationId == organizationId && membership.UserId == userId)
|
||||
.Select(membership => membership.Role)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
return roles.Any(role => OrganizationPermissionRules.RoleHasPermission(role, permission));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<string>> GetUserOrganizationPermissionsAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid organizationId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (IsGlobalManager(user))
|
||||
{
|
||||
return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
|
||||
}
|
||||
|
||||
Guid userId = user.GetUserId();
|
||||
|
||||
bool owner = await dbContext.Organizations.AnyAsync(
|
||||
organization => organization.Id == organizationId && organization.OwnerUserId == userId,
|
||||
ct);
|
||||
if (owner)
|
||||
{
|
||||
return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
|
||||
}
|
||||
|
||||
string[] roles = await dbContext.OrganizationMemberships
|
||||
.Where(membership => membership.OrganizationId == organizationId && membership.UserId == userId)
|
||||
.Select(membership => membership.Role)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
return roles
|
||||
.SelectMany(OrganizationPermissionRules.GetPermissionsForRole)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(permission => permission)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<bool> HasInheritedWorkspacePermissionAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid workspaceId,
|
||||
string permission,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (IsGlobalManager(user))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Guid? organizationId = await dbContext.Workspaces
|
||||
.Where(workspace => workspace.Id == workspaceId)
|
||||
.Select(workspace => (Guid?)workspace.OrganizationId)
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
return organizationId.HasValue &&
|
||||
await HasOrganizationPermissionAsync(user, organizationId.Value, permission, ct);
|
||||
}
|
||||
|
||||
private async Task<Guid[]> GetInheritedWorkspaceIdsAsync(
|
||||
ClaimsPrincipal user,
|
||||
string permission,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid userId = user.GetUserId();
|
||||
|
||||
Guid[] ownedOrganizationIds = await dbContext.Organizations
|
||||
.Where(organization => organization.OwnerUserId == userId)
|
||||
.Select(organization => organization.Id)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
List<Data.OrganizationMembership> memberships = await dbContext.OrganizationMemberships
|
||||
.Where(membership => membership.UserId == userId)
|
||||
.ToListAsync(ct);
|
||||
Guid[] memberOrganizationIds = memberships
|
||||
.Where(membership => OrganizationPermissionRules.RoleHasPermission(membership.Role, permission))
|
||||
.Select(membership => membership.OrganizationId)
|
||||
.ToArray();
|
||||
|
||||
Guid[] organizationIds = ownedOrganizationIds
|
||||
.Concat(memberOrganizationIds)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (organizationIds.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.Workspaces
|
||||
.Where(workspace => organizationIds.Contains(workspace.OrganizationId))
|
||||
.Select(workspace => workspace.Id)
|
||||
.ToArrayAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
public static class OrganizationPermissionRules
|
||||
{
|
||||
public static IReadOnlyCollection<string> GetPermissionsForRole(string role)
|
||||
{
|
||||
return role switch
|
||||
{
|
||||
OrganizationRoles.Owner =>
|
||||
[
|
||||
OrganizationPermissions.ManageOrganizationSettings,
|
||||
OrganizationPermissions.ManageOrganizationMembers,
|
||||
OrganizationPermissions.CreateWorkspaces,
|
||||
OrganizationPermissions.ManageWorkspaces,
|
||||
OrganizationPermissions.ManageBilling,
|
||||
OrganizationPermissions.ManageConnectors,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
],
|
||||
OrganizationRoles.Admin =>
|
||||
[
|
||||
OrganizationPermissions.ManageOrganizationSettings,
|
||||
OrganizationPermissions.ManageOrganizationMembers,
|
||||
OrganizationPermissions.CreateWorkspaces,
|
||||
OrganizationPermissions.ManageWorkspaces,
|
||||
OrganizationPermissions.ManageConnectors,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
],
|
||||
OrganizationRoles.BillingManager =>
|
||||
[
|
||||
OrganizationPermissions.ManageBilling,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
],
|
||||
OrganizationRoles.ConnectorManager =>
|
||||
[
|
||||
OrganizationPermissions.ManageConnectors,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
],
|
||||
OrganizationRoles.Member =>
|
||||
[
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
],
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
public static bool RoleHasPermission(string role, string permission)
|
||||
{
|
||||
return GetPermissionsForRole(role).Contains(permission, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
public static class OrganizationPermissions
|
||||
{
|
||||
public const string ManageOrganizationSettings = "ManageOrganizationSettings";
|
||||
public const string ManageOrganizationMembers = "ManageOrganizationMembers";
|
||||
public const string CreateWorkspaces = "CreateWorkspaces";
|
||||
public const string ManageWorkspaces = "ManageWorkspaces";
|
||||
public const string ManageBilling = "ManageBilling";
|
||||
public const string ManageConnectors = "ManageConnectors";
|
||||
public const string AccessOwnedWorkspaces = "AccessOwnedWorkspaces";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
public static class OrganizationRoles
|
||||
{
|
||||
public const string Owner = "Owner";
|
||||
public const string Admin = "Admin";
|
||||
public const string BillingManager = "BillingManager";
|
||||
public const string ConnectorManager = "ConnectorManager";
|
||||
public const string Member = "Member";
|
||||
}
|
||||
Reference in New Issue
Block a user