feat: add organization domain foundation

This commit is contained in:
2026-05-04 16:15:53 -04:00
parent 802668fb0b
commit 7d3f495472
55 changed files with 2995 additions and 115 deletions

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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";
}

View File

@@ -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";
}