feat: add organization domain foundation
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
1514
backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.Designer.cs
generated
Normal file
1514
backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Tests.Organizations;
|
||||
|
||||
public class OrganizationPermissionRulesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Owner_has_all_initial_organization_permissions()
|
||||
{
|
||||
IReadOnlyCollection<string> permissions = OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
|
||||
|
||||
Assert.Contains(OrganizationPermissions.ManageOrganizationSettings, permissions);
|
||||
Assert.Contains(OrganizationPermissions.ManageOrganizationMembers, permissions);
|
||||
Assert.Contains(OrganizationPermissions.CreateWorkspaces, permissions);
|
||||
Assert.Contains(OrganizationPermissions.ManageWorkspaces, permissions);
|
||||
Assert.Contains(OrganizationPermissions.ManageBilling, permissions);
|
||||
Assert.Contains(OrganizationPermissions.ManageConnectors, permissions);
|
||||
Assert.Contains(OrganizationPermissions.AccessOwnedWorkspaces, permissions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_does_not_receive_billing_permission_by_default()
|
||||
{
|
||||
IReadOnlyCollection<string> permissions = OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Admin);
|
||||
|
||||
Assert.Contains(OrganizationPermissions.ManageOrganizationSettings, permissions);
|
||||
Assert.Contains(OrganizationPermissions.ManageOrganizationMembers, permissions);
|
||||
Assert.Contains(OrganizationPermissions.CreateWorkspaces, permissions);
|
||||
Assert.Contains(OrganizationPermissions.ManageWorkspaces, permissions);
|
||||
Assert.Contains(OrganizationPermissions.ManageConnectors, permissions);
|
||||
Assert.Contains(OrganizationPermissions.AccessOwnedWorkspaces, permissions);
|
||||
Assert.DoesNotContain(OrganizationPermissions.ManageBilling, permissions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OrganizationRoles.BillingManager, OrganizationPermissions.ManageBilling, true)]
|
||||
[InlineData(OrganizationRoles.BillingManager, OrganizationPermissions.ManageConnectors, false)]
|
||||
[InlineData(OrganizationRoles.ConnectorManager, OrganizationPermissions.ManageConnectors, true)]
|
||||
[InlineData(OrganizationRoles.ConnectorManager, OrganizationPermissions.ManageBilling, false)]
|
||||
[InlineData(OrganizationRoles.Member, OrganizationPermissions.AccessOwnedWorkspaces, true)]
|
||||
[InlineData(OrganizationRoles.Member, OrganizationPermissions.ManageWorkspaces, false)]
|
||||
public void RoleHasPermission_enforces_role_permission_mapping(
|
||||
string role,
|
||||
string permission,
|
||||
bool expected)
|
||||
{
|
||||
bool actual = OrganizationPermissionRules.RoleHasPermission(role, permission);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
Draft
|
||||
Ready for initial backend implementation.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -173,6 +173,32 @@ Workspace-level screens remain centered on the selected workspace.
|
||||
- Workspace APIs must preserve workspace scoping and account for inherited organization permissions.
|
||||
- New backend contracts require OpenAPI regeneration while the backend is running.
|
||||
|
||||
## Implementation Readiness
|
||||
|
||||
The initial implementation should proceed through the task files in `docs/TASKS/organizations/`.
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. `001-organization-domain-foundation.md`
|
||||
2. `002-organization-membership-permissions.md`
|
||||
3. `003-organization-settings-ui.md`
|
||||
4. `004-workspace-selector-organization-switcher.md`
|
||||
|
||||
Task 001 should establish the organization table, workspace ownership, current-user organization read APIs, and development bootstrap data. It should not attempt the full inherited permission model beyond enough access data to prove a user can access their organizations.
|
||||
|
||||
Task 002 should introduce organization memberships and explicit organization permissions before frontend settings or switcher work relies on permission-gated data.
|
||||
|
||||
Frontend tasks should start only after backend contracts have been regenerated into `shared/openapi/openapi.json` and `frontend/src/api/schema.d.ts`.
|
||||
|
||||
Initial backend routes:
|
||||
|
||||
```txt
|
||||
GET /api/organizations
|
||||
GET /api/organizations/{organizationId}
|
||||
```
|
||||
|
||||
Workspace responses should include `organizationId` once workspaces are owned by organizations.
|
||||
|
||||
## Out Of Scope For Initial Implementation
|
||||
|
||||
- Preserving existing local data through migration. Development data can be wiped.
|
||||
|
||||
@@ -17,12 +17,14 @@ Existing local data does not need to be preserved.
|
||||
## Scope
|
||||
|
||||
- Add an `Organizations` backend module or follow the existing ownership pattern if organization code belongs with `Workspaces`.
|
||||
- Add an organization persistence model with name, slug/display identity, timestamps, and basic audit fields matching local conventions.
|
||||
- Add an organization persistence model with `Id`, `Name`, `Slug`, `OwnerUserId`, and `CreatedAt`, matching local conventions.
|
||||
- Require every workspace to belong to exactly one organization.
|
||||
- Update workspace create/list/detail APIs to include organization ownership.
|
||||
- Add APIs for current user organization list and organization detail.
|
||||
- Add current-user organization read APIs:
|
||||
- `GET /api/organizations`
|
||||
- `GET /api/organizations/{organizationId}`
|
||||
- Add backend validation that users cannot access organizations they have no relationship with.
|
||||
- Seed or development bootstrap data should create at least one organization and owned workspace.
|
||||
- Seed or development bootstrap data should create at least one organization and owned workspace when local development data is empty.
|
||||
- Update OpenAPI after backend contracts change.
|
||||
|
||||
## Constraints
|
||||
@@ -32,8 +34,21 @@ Existing local data does not need to be preserved.
|
||||
- Do not implement billing provider integration in this task.
|
||||
- Do not implement connector storage in this task.
|
||||
- Do not implement full organization membership override behavior in this task.
|
||||
- Do not implement organization settings UI in this task.
|
||||
- Existing development data may be wiped; do not spend scope on compatibility migration behavior.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Place organization-owned backend code under `backend/src/Socialize.Api/Modules/Organizations`.
|
||||
- Add `DbSet<Organization>` and `ConfigureOrganizationsModule()` to `AppDbContext`.
|
||||
- Keep `Workspace.OwnerUserId` for the existing creator/owner convention unless a later task explicitly replaces it.
|
||||
- Add a required `Workspace.OrganizationId` property and database index.
|
||||
- The first implementation may grant organization access through `Organization.OwnerUserId == currentUserId` and existing manager/administrator access. Full organization membership belongs to task 002.
|
||||
- `CreateWorkspaceRequest` should require `OrganizationId`; reject creation when the user cannot manage that organization.
|
||||
- `WorkspaceDto` should include `OrganizationId`.
|
||||
- Slugs should keep the existing lowercase kebab-case validation used for workspaces.
|
||||
- Use tests for unauthorized organization detail access and workspace creation under an inaccessible organization.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
||||
@@ -46,15 +61,15 @@ Existing local data does not need to be preserved.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Organization entity is persisted.
|
||||
- [ ] Workspace requires `OrganizationId`.
|
||||
- [ ] Workspace APIs expose organization ownership.
|
||||
- [ ] Current user can list accessible organizations.
|
||||
- [ ] Current user can get accessible organization details.
|
||||
- [ ] Unauthorized organization access is rejected.
|
||||
- [ ] Development seed data creates an organization with owned workspaces.
|
||||
- [ ] Backend build and tests pass.
|
||||
- [ ] OpenAPI and generated frontend schema are updated.
|
||||
- [x] Organization entity is persisted.
|
||||
- [x] Workspace requires `OrganizationId`.
|
||||
- [x] Workspace APIs expose organization ownership.
|
||||
- [x] Current user can list accessible organizations.
|
||||
- [x] Current user can get accessible organization details.
|
||||
- [x] Unauthorized organization access is rejected.
|
||||
- [x] Development seed data creates an organization with owned workspaces.
|
||||
- [x] Backend build and tests pass.
|
||||
- [x] OpenAPI and generated frontend schema are updated.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
|
||||
@@ -36,6 +36,40 @@ Users have global accounts. A user can have rights in multiple organizations and
|
||||
- External collaborators must not become organization members automatically.
|
||||
- Keep permission names explicit; avoid magic strings where local patterns provide constants.
|
||||
|
||||
## Permission Model
|
||||
|
||||
Use explicit constants in the Organizations module rather than raw strings in handlers.
|
||||
|
||||
Initial organization permissions:
|
||||
|
||||
- `ManageOrganizationSettings`
|
||||
- `ManageOrganizationMembers`
|
||||
- `CreateWorkspaces`
|
||||
- `ManageWorkspaces`
|
||||
- `ManageBilling`
|
||||
- `ManageConnectors`
|
||||
- `AccessOwnedWorkspaces`
|
||||
|
||||
Initial organization roles should map to permissions in code:
|
||||
|
||||
- `Owner`: all organization permissions.
|
||||
- `Admin`: organization settings, organization members, workspace creation, workspace administration, connector management, and owned workspace access. Billing is not included unless explicitly assigned.
|
||||
- `BillingManager`: billing and owned workspace access.
|
||||
- `ConnectorManager`: connector management and owned workspace access.
|
||||
- `Member`: owned workspace access only.
|
||||
|
||||
Workspace-specific permissions may be overridden at the workspace level after inherited organization access is resolved. Billing and connector permissions must never be granted from workspace-level overrides.
|
||||
|
||||
Direct workspace members who are not organization members should be labeled `External Collaborator` in workspace membership responses. Organization members with inherited or direct workspace access should be labeled `Organization Member`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Add an `OrganizationMembership` persistence model with `OrganizationId`, `UserId`, role/permission data, and `CreatedAt`.
|
||||
- Prefer a small Organizations access service for organization access checks and inherited workspace permission calculation instead of adding ad hoc queries to every handler.
|
||||
- Update JWT claims only if a task proves claims are needed; permission checks can query current database state first.
|
||||
- Preserve existing global Identity roles while introducing organization-scoped roles. Do not reuse global `manager`, `client`, or `provider` roles as organization roles.
|
||||
- Add unit tests for role-to-permission mapping and handler/integration tests for access rejection where existing test infrastructure supports it.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `backend/src/Socialize.Api/Modules/Organizations/**`
|
||||
@@ -46,12 +80,12 @@ Users have global accounts. A user can have rights in multiple organizations and
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Organization memberships are persisted.
|
||||
- [ ] Organization roles/permissions include billing manager.
|
||||
- [ ] Organization-level access can grant inherited access to owned workspaces.
|
||||
- [ ] Direct workspace-only external collaborators remain supported.
|
||||
- [ ] Workspace-level overrides apply to workspace-specific permissions.
|
||||
- [ ] Billing and connector permissions cannot be granted through workspace overrides.
|
||||
- [x] Organization memberships are persisted.
|
||||
- [x] Organization roles/permissions include billing manager.
|
||||
- [x] Organization-level access can grant inherited access to owned workspaces.
|
||||
- [x] Direct workspace-only external collaborators remain supported.
|
||||
- [x] Workspace-level overrides apply to workspace-specific permissions.
|
||||
- [x] Billing and connector permissions cannot be granted through workspace overrides.
|
||||
- [ ] Backend tests cover inherited, direct, external collaborator, and override access paths.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
117
frontend/src/api/schema.d.ts
vendored
117
frontend/src/api/schema.d.ts
vendored
@@ -100,6 +100,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations/{organizationId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationHandler"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/notifications": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -878,6 +910,8 @@ export interface components {
|
||||
SocializeApiModulesWorkspacesHandlersWorkspaceDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
/** Format: guid */
|
||||
organizationId?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
logoUrl?: string | null;
|
||||
@@ -906,6 +940,8 @@ export interface components {
|
||||
createdAt?: string;
|
||||
};
|
||||
SocializeApiModulesWorkspacesHandlersCreateWorkspaceRequest: {
|
||||
/** Format: guid */
|
||||
organizationId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
timeZone: string;
|
||||
@@ -932,6 +968,7 @@ export interface components {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
portraitUrl?: string | null;
|
||||
relationshipCategory?: string;
|
||||
roles?: string[];
|
||||
};
|
||||
SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: {
|
||||
@@ -952,6 +989,30 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
requiredApproverCount?: number;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
/** Format: guid */
|
||||
ownerUserId?: string;
|
||||
currentUserPermissions?: string[];
|
||||
members?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"][];
|
||||
workspaces?: components["schemas"]["SocializeApiModulesWorkspacesHandlersWorkspaceDto"][];
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
|
||||
/** Format: guid */
|
||||
userId?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
portraitUrl?: string | null;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
@@ -1778,6 +1839,62 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersGetOrganizationHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
organizationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"][];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesNotificationsHandlersGetNotificationsHandler: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -385,6 +385,78 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/organizations/{organizationId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Organizations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesOrganizationsHandlersGetOrganizationHandler",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "organizationId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/organizations": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Organizations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/notifications": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2932,6 +3004,10 @@
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
},
|
||||
"organizationId": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3008,11 +3084,18 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"organizationId",
|
||||
"name",
|
||||
"slug",
|
||||
"timeZone"
|
||||
],
|
||||
"properties": {
|
||||
"organizationId": {
|
||||
"type": "string",
|
||||
"format": "guid",
|
||||
"minLength": 1,
|
||||
"nullable": false
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
@@ -3102,6 +3185,9 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"relationshipCategory": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -3178,6 +3264,81 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersOrganizationDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"slug": {
|
||||
"type": "string"
|
||||
},
|
||||
"ownerUserId": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
},
|
||||
"currentUserPermissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"members": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"
|
||||
}
|
||||
},
|
||||
"workspaces": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesWorkspacesHandlersWorkspaceDto"
|
||||
}
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersOrganizationMemberDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"userId": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"portraitUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesNotificationsHandlersNotificationEventDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
Reference in New Issue
Block a user