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

@@ -9,6 +9,7 @@ using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Data;
@@ -17,6 +18,8 @@ public class AppDbContext(
DbContextOptions<AppDbContext> options)
: IdentityDbContext<User, Role, Guid>(options)
{
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
public DbSet<Client> Clients => Set<Client>();
@@ -41,6 +44,7 @@ public class AppDbContext(
{
base.OnModelCreating(builder);
builder.ConfigureOrganizationsModule();
builder.ConfigureWorkspacesModule();
builder.ConfigureClientsModule();
builder.ConfigureCampaignsModule();

View File

@@ -11,6 +11,8 @@ using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
@@ -19,6 +21,7 @@ namespace Socialize.Api.Infrastructure.Development;
public static class DevelopmentSeedExtensions
{
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
@@ -117,6 +120,11 @@ public static class DevelopmentSeedExtensions
[
]);
await EnsureOrganizationDataAsync(
manager.Id,
dbContext,
cancellationToken);
await EnsureWorkspaceDataAsync(
manager.Id,
clientUser.Id,
@@ -224,6 +232,50 @@ public static class DevelopmentSeedExtensions
return user;
}
private static async Task EnsureOrganizationDataAsync(
Guid managerUserId,
AppDbContext dbContext,
CancellationToken cancellationToken)
{
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == OrganizationId, cancellationToken);
if (organization is null)
{
organization = new Organization
{
Id = OrganizationId,
Name = string.Empty,
Slug = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Organizations.Add(organization);
}
organization.Name = "Northstar Collective";
organization.Slug = "northstar-collective";
organization.OwnerUserId = managerUserId;
OrganizationMembership? membership = await dbContext.OrganizationMemberships
.SingleOrDefaultAsync(
candidate => candidate.OrganizationId == OrganizationId && candidate.UserId == managerUserId,
cancellationToken);
if (membership is null)
{
membership = new OrganizationMembership
{
Id = Guid.Parse("99999999-9999-9999-9999-000000000001"),
OrganizationId = OrganizationId,
UserId = managerUserId,
Role = OrganizationRoles.Owner,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.OrganizationMemberships.Add(membership);
}
membership.Role = OrganizationRoles.Owner;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task EnsureWorkspaceDataAsync(
Guid managerUserId,
Guid clientUserId,
@@ -248,6 +300,7 @@ public static class DevelopmentSeedExtensions
workspace.Name = "Northstar Studio";
workspace.Slug = "northstar-studio";
workspace.OrganizationId = OrganizationId;
workspace.OwnerUserId = managerUserId;
workspace.TimeZone = "America/Montreal";
await dbContext.SaveChangesAsync(cancellationToken);

View File

@@ -1,9 +1,11 @@
using System.Security.Claims;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Infrastructure.Security;
public sealed class AccessScopeService
public sealed class AccessScopeService(
OrganizationAccessService organizationAccessService)
{
public bool IsManager(ClaimsPrincipal user)
{
@@ -53,4 +55,123 @@ public sealed class AccessScopeService
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
}
public Task<IReadOnlyCollection<Guid>> GetAccessibleWorkspaceIdsAsync(
ClaimsPrincipal user,
CancellationToken ct)
{
return organizationAccessService.GetAccessibleWorkspaceIdsAsync(user, ct);
}
public async Task<bool> CanAccessWorkspaceAsync(
ClaimsPrincipal user,
Guid workspaceId,
CancellationToken ct)
{
return CanAccessWorkspace(user, workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct);
}
public async Task<bool> CanManageWorkspaceAsync(
ClaimsPrincipal user,
Guid workspaceId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.ManageWorkspaces,
ct);
}
public async Task<bool> CanCreateWorkspaceAsync(
ClaimsPrincipal user,
Guid organizationId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasOrganizationPermissionAsync(
user,
organizationId,
OrganizationPermissions.CreateWorkspaces,
ct);
}
public async Task<bool> CanAccessClientAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct))
{
return true;
}
return user.GetWorkspaceScopeIds().Contains(workspaceId) && user.GetClientScopeIds().Contains(clientId);
}
public async Task<bool> CanAccessCampaignAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct))
{
return true;
}
return await CanAccessClientAsync(user, workspaceId, clientId, ct) &&
user.GetCampaignScopeIds().Contains(campaignId);
}
public async Task<bool> CanContributeToCampaignAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.ManageWorkspaces,
ct)
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct);
}
public async Task<bool> CanReviewContentAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct)
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddOrganizations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OrganizationId",
table: "Workspaces",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateTable(
name: "Organizations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Slug = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Organizations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OrganizationMemberships",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_OrganizationMemberships", x => x.Id);
table.ForeignKey(
name: "FK_OrganizationMemberships_Organizations_OrganizationId",
column: x => x.OrganizationId,
principalTable: "Organizations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql(
"""
INSERT INTO "Organizations" ("Id", "Name", "Slug", "OwnerUserId", "CreatedAt")
VALUES ('99999999-9999-9999-9999-999999999999', 'Northstar Collective', 'northstar-collective', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', CURRENT_TIMESTAMP);
UPDATE "Workspaces"
SET "OrganizationId" = '99999999-9999-9999-9999-999999999999'
WHERE "OrganizationId" = '00000000-0000-0000-0000-000000000000';
INSERT INTO "OrganizationMemberships" ("Id", "OrganizationId", "UserId", "Role", "CreatedAt")
VALUES ('99999999-9999-9999-9999-000000000001', '99999999-9999-9999-9999-999999999999', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Owner', CURRENT_TIMESTAMP);
""");
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OrganizationId",
table: "Workspaces",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_OrganizationMemberships_OrganizationId",
table: "OrganizationMemberships",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_OrganizationMemberships_OrganizationId_UserId",
table: "OrganizationMemberships",
columns: new[] { "OrganizationId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OrganizationMemberships_UserId",
table: "OrganizationMemberships",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Organizations_OwnerUserId",
table: "Organizations",
column: "OwnerUserId");
migrationBuilder.CreateIndex(
name: "IX_Organizations_Slug",
table: "Organizations",
column: "Slug",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Workspaces_Organizations_OrganizationId",
table: "Workspaces",
column: "OrganizationId",
principalTable: "Organizations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Workspaces_Organizations_OrganizationId",
table: "Workspaces");
migrationBuilder.DropTable(
name: "OrganizationMemberships");
migrationBuilder.DropTable(
name: "Organizations");
migrationBuilder.DropIndex(
name: "IX_Workspaces_OrganizationId",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "OrganizationId",
table: "Workspaces");
}
}
}

View File

@@ -1203,6 +1203,74 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Organizations", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("UserId");
b.HasIndex("OrganizationId", "UserId")
.IsUnique();
b.ToTable("OrganizationMemberships", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
@@ -1235,6 +1303,9 @@ namespace Socialize.Api.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
@@ -1260,6 +1331,8 @@ namespace Socialize.Api.Migrations
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("OwnerUserId");
b.HasIndex("Slug")
@@ -1404,6 +1477,24 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{
b.Navigation("ActivityEntries");

View File

@@ -61,7 +61,7 @@ public class GetApprovalsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -64,7 +64,7 @@ public class SubmitApprovalDecisionHandler(
}
if (User?.Identity?.IsAuthenticated == true &&
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -51,7 +51,7 @@ public class CreateAssetRevisionHandler(
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
if (contentItem is not null &&
!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -58,7 +58,7 @@ public class CreateGoogleDriveAssetHandler(
return;
}
if (!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -52,7 +52,7 @@ public class GetAssetsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -45,7 +45,7 @@ public class CreateCampaignHandler(
public override async Task HandleAsync(CreateCampaignRequest request, CancellationToken ct)
{
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -34,16 +34,9 @@ public class GetCampaignsHandler(
{
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
if (accessScopeService.IsManager(User))
if (!accessScopeService.IsManager(User))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();

View File

@@ -47,7 +47,7 @@ public class ChangeClientPortraitHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -41,7 +41,7 @@ public class CreateClientHandler(
public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct)
{
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -33,16 +33,9 @@ public class GetClientsHandler(
{
IQueryable<Client> query = dbContext.Clients.AsQueryable();
if (accessScopeService.IsManager(User))
if (!accessScopeService.IsManager(User))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
@@ -52,10 +45,11 @@ public class GetClientsHandler(
query = query.Where(client => clientScopeIds.Contains(client.Id));
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
List<ClientDto> clients = await query

View File

@@ -50,7 +50,7 @@ public class UpdateClientHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -51,7 +51,7 @@ public class CreateCommentHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -44,7 +44,7 @@ public class GetCommentsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -39,8 +39,8 @@ public class ResolveCommentHandler(
return;
}
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|| accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId);
bool canResolve = await accessScopeService.CanManageWorkspaceAsync(User, comment.WorkspaceId, ct)
|| await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct);
if (!canResolve)
{

View File

@@ -47,7 +47,7 @@ public class CreateContentItemHandler(
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
{
if (!accessScopeService.CanContributeToCampaign(User, request.WorkspaceId, request.ClientId, request.CampaignId))
if (!await accessScopeService.CanContributeToCampaignAsync(User, request.WorkspaceId, request.ClientId, request.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -50,7 +50,7 @@ public class CreateContentItemRevisionHandler(
return;
}
if (!accessScopeService.CanContributeToCampaign(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanContributeToCampaignAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -60,7 +60,7 @@ public class GetContentItemHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -41,7 +41,7 @@ public class GetContentItemRevisionsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -39,7 +39,7 @@ public class GetContentItemsHandler(
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();

View File

@@ -54,7 +54,7 @@ public class UpdateContentItemStatusHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, item.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -46,7 +46,7 @@ public class GetNotificationsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -58,7 +58,7 @@ public class GetNotificationsHandler(
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(notificationEvent =>
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
notificationEvent.RecipientUserId == currentUserId);

View File

@@ -30,7 +30,7 @@ public class MarkNotificationAsReadHandler(
Guid currentUserId = User.GetUserId();
bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId;
if (!canReadRecipientNotification && !accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
if (!canReadRecipientNotification && !await accessScopeService.CanAccessWorkspaceAsync(User, notificationEvent.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

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

View File

@@ -6,6 +6,7 @@ public class Workspace
public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; }
public Guid OrganizationId { get; set; }
public Guid OwnerUserId { get; set; }
public required string TimeZone { get; set; }
public string ApprovalMode { get; set; } = "Required";

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.Organizations.Data;
namespace Socialize.Api.Modules.Workspaces.Data;
@@ -22,7 +23,12 @@ public static class WorkspaceModelConfiguration
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workspace.HasIndex(x => x.Slug).IsUnique();
workspace.HasIndex(x => x.OrganizationId);
workspace.HasIndex(x => x.OwnerUserId);
workspace.HasOne<Organization>()
.WithMany()
.HasForeignKey(x => x.OrganizationId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<WorkspaceInvite>(workspaceInvite =>

View File

@@ -47,7 +47,7 @@ public class ChangeWorkspaceLogoHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -7,6 +7,7 @@ using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record CreateWorkspaceRequest(
Guid OrganizationId,
string Name,
string Slug,
string TimeZone);
@@ -16,6 +17,7 @@ public class CreateWorkspaceRequestValidator
{
public CreateWorkspaceRequestValidator()
{
RuleFor(x => x.OrganizationId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug)
.NotEmpty()
@@ -38,12 +40,21 @@ public class CreateWorkspaceHandler(
public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct)
{
if (!accessScopeService.IsManager(User))
if (!await accessScopeService.CanCreateWorkspaceAsync(User, request.OrganizationId, ct))
{
await SendForbiddenAsync(ct);
return;
}
bool organizationExists = await dbContext.Organizations
.AnyAsync(organization => organization.Id == request.OrganizationId, ct);
if (!organizationExists)
{
AddError(request => request.OrganizationId, "The selected organization does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedSlug = request.Slug.Trim().ToLowerInvariant();
string normalizedTimeZone = request.TimeZone.Trim();
@@ -61,6 +72,7 @@ public class CreateWorkspaceHandler(
Workspace workspace = new()
{
Id = Guid.NewGuid(),
OrganizationId = request.OrganizationId,
Name = normalizedName,
Slug = normalizedSlug,
OwnerUserId = User.GetUserId(),
@@ -71,18 +83,7 @@ public class CreateWorkspaceHandler(
dbContext.Workspaces.Add(workspace);
await dbContext.SaveChangesAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
[],
workspace.CreatedAt);
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, []);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}

View File

@@ -43,7 +43,7 @@ public class CreateWorkspaceInviteHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -29,7 +29,7 @@ public class GetWorkspaceInvitesHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -4,6 +4,7 @@ using System.Security.Claims;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
@@ -12,6 +13,7 @@ public record WorkspaceMemberDto(
string DisplayName,
string Email,
string? PortraitUrl,
string RelationshipCategory,
IReadOnlyCollection<string> Roles);
public class GetWorkspaceMembersHandler(
@@ -29,12 +31,20 @@ public class GetWorkspaceMembersHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
string workspaceClaimValue = workspaceId.ToString();
var users = await dbContext.Users
@@ -42,7 +52,11 @@ public class GetWorkspaceMembersHandler(
dbContext.UserClaims.Any(claim =>
claim.UserId == candidate.Id &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue))
claim.ClaimValue == workspaceClaimValue) ||
dbContext.OrganizationMemberships.Any(membership =>
membership.UserId == candidate.Id &&
membership.OrganizationId == workspace.OrganizationId) ||
candidate.Id == workspace.OwnerUserId)
.OrderBy(candidate => candidate.Lastname)
.ThenBy(candidate => candidate.Firstname)
.ThenBy(candidate => candidate.Email)
@@ -70,12 +84,19 @@ public class GetWorkspaceMembersHandler(
.ToArray(),
ct);
HashSet<Guid> organizationMemberUserIds = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == workspace.OrganizationId)
.Select(membership => membership.UserId)
.ToHashSetAsync(ct);
organizationMemberUserIds.Add(workspace.OwnerUserId);
var members = users
.Select(candidate => new WorkspaceMemberDto(
candidate.Id,
BuildDisplayName(candidate),
candidate.Email ?? string.Empty,
candidate.PortraitUrl,
organizationMemberUserIds.Contains(candidate.Id) ? "Organization Member" : "External Collaborator",
rolesByUserId.GetValueOrDefault(candidate.Id) ?? Array.Empty<string>()))
.ToList();

View File

@@ -19,6 +19,7 @@ public record ApprovalStepConfigurationDto(
public record WorkspaceDto(
Guid Id,
Guid OrganizationId,
string Name,
string Slug,
string? LogoUrl,
@@ -28,7 +29,27 @@ public record WorkspaceDto(
bool LockContentAfterApproval,
bool SendAutomaticApprovalReminders,
IReadOnlyCollection<ApprovalStepConfigurationDto> ApprovalSteps,
DateTimeOffset CreatedAt);
DateTimeOffset CreatedAt)
{
public static WorkspaceDto FromWorkspace(
Workspace workspace,
IReadOnlyCollection<ApprovalStepConfigurationDto> approvalSteps)
{
return new WorkspaceDto(
workspace.Id,
workspace.OrganizationId,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
}
}
internal class GetWorkspacesHandler(
AppDbContext dbContext,
@@ -43,13 +64,9 @@ internal class GetWorkspacesHandler(
public override async Task HandleAsync(CancellationToken ct)
{
var query = dbContext.Workspaces.AsQueryable();
if (!accessScopeService.IsManager(User))
{
var workspaceScopeIds = User.GetWorkspaceScopeIds();
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
}
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
var query = dbContext.Workspaces
.Where(workspace => accessibleWorkspaceIds.Contains(workspace.Id));
var workspaceRows = await query
.OrderBy(workspace => workspace.Name)
@@ -71,18 +88,9 @@ internal class GetWorkspacesHandler(
.ToArray());
var workspaces = workspaceRows
.Select(workspace => new WorkspaceDto(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>(),
workspace.CreatedAt))
.Select(workspace => WorkspaceDto.FromWorkspace(
workspace,
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>()))
.ToList();
await SendOkAsync(workspaces, ct);

View File

@@ -73,7 +73,7 @@ public class UpdateWorkspaceHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -154,18 +154,7 @@ public class UpdateWorkspaceHandler(
step.CreatedAt))
.ToListAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, approvalSteps);
await SendOkAsync(dto, ct);
}

View File

@@ -17,6 +17,7 @@ using Socialize.Api.Modules.Feedback;
using Socialize.Api.Modules.Identity;
using Socialize.Api.Modules.Notifications;
using Socialize.Api.Modules.Campaigns;
using Socialize.Api.Modules.Organizations;
using Socialize.Api.Modules.Workspaces;
@@ -62,6 +63,7 @@ var postgresConnectionString = builder.Configuration.GetConnectionString("Postgr
builder.Services.AddAppData(postgresConnectionString);
builder.AddInfrastructureModule();
builder.AddIdentityModule();
builder.AddOrganizationsModule();
builder.AddWorkspaceModule();
builder.AddClientsModule();
builder.AddCampaignsModule();