diff --git a/README.md b/README.md index 577bd8d..60c9be9 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ - `docs/TECH_STACK.md`:技術棧與選型 - `docs/INSTALL.md`:安裝、初始化與維運指令 - `docs/MEMBER_UPGRADE_PLAN.md`:會員中心下一階段升級規劃(設定畫面、SMTP、Email 驗證、忘記密碼、角色分級) +- `docs/ADMIN_AUTHORIZATION.md`:後台 Role / Permission 權限模型與管理原則 - `docs/TEST_SITE.md`:手動整合測試站說明(API login、redirect login、會員 API happy path) ## 專案結構 diff --git a/docs/ADMIN_AUTHORIZATION.md b/docs/ADMIN_AUTHORIZATION.md new file mode 100644 index 0000000..7f725b3 --- /dev/null +++ b/docs/ADMIN_AUTHORIZATION.md @@ -0,0 +1,90 @@ +# Admin Authorization + +## Purpose + +The admin authorization model separates a user's organizational role from the +individual admin capabilities granted to that role. The schema supports multiple +roles even though the initial non-superuser role is only `admin`. + +## Core Rules + +1. `superuser` is a hard-coded emergency and governance role. + - It does not depend on database permission mappings. + - It bypasses all admin permission checks. + - Creating a replacement superuser or resetting its password remains an + installer responsibility. + - Superuser-only account governance actions continue to require the + `Superuser` authorization policy. + +2. Normal admin access uses Role-Based Access Control. + - A user may have one or more Identity roles through `user_roles`. + - A role may have one or more admin permissions through + `admin_role_permissions`. + - A user's effective permissions are the union of permissions assigned to all + of the user's roles. + +3. Roles and role-permission mappings are database data. + - Existing ASP.NET Core Identity `roles` and `user_roles` tables remain the + source of role membership. + - `admin_permissions` stores the known permission catalog. + - `admin_role_permissions` maps any Identity role to any admin permission. + - Adding roles such as `support`, `auditor`, or `security_admin` does not + require another schema change. + +4. Permission definitions are owned by application code. + - Permission keys are declared in `AdminPermissions`. + - Startup and installer seeding synchronize those known definitions into the + database. + - The database decides which roles receive known permissions; it must not be + used to invent capabilities that have no application implementation. + +5. Permissions are action-oriented. + - Controllers require a module permission such as `admin.tenants`. + - Actions also require a capability permission such as + `admin.tenants.create` or `admin.tenants.delete`. + - Operations with materially different authorization boundaries must be + separate actions and separate permission keys. + +6. Server-side checks are authoritative. + - Admin controllers retain `[Authorize(Policy = "Admin")]` as the outer admin + boundary. + - Controllers and actions use `AdminPermissionAttribute` for database-backed + permission checks. + - Non-admin access to `/admin/*` continues to return HTTP 404. + - Missing action permissions also return HTTP 404 so inaccessible admin + capabilities are not exposed. + +7. Navigation and operation links use the same permission catalog. + - Admin menus reference the same `AdminPermissions` constants used by the + corresponding controller actions. + - Buttons and links are hidden when the current user lacks the target action + permission. + - UI visibility is only a usability measure; direct requests are always + protected by controller/action checks. + +## Initial Mapping + +The initial `admin` role receives every known permission except permissions +marked as superuser-only. This preserves current behavior while allowing the +mapping to be divided among more roles later. + +The initial superuser-only permissions are: + +- `admin.accounts.set_admin` +- `admin.accounts.set_disabled` +- `admin.accounts.reset_password` + +No role or permission maintenance UI is included yet. Until one is introduced, +the permission catalog is updated in code and role-permission mappings may be +managed through controlled database changes or future installer commands. + +## Adding an Admin Capability + +1. Add a constant and definition to `AdminPermissions`. +2. Apply the module permission to the controller. +3. Apply the capability permission to every relevant action. +4. Use the same action permission for related menu items, links, and buttons. +5. Add or update role-permission mappings through the approved seed or + administrative workflow. +6. Verify direct access, navigation visibility, `admin`, and `superuser` + behavior. diff --git a/src/MemberCenter.Api/Authorization/AdminPermissionAttribute.cs b/src/MemberCenter.Api/Authorization/AdminPermissionAttribute.cs new file mode 100644 index 0000000..dfca829 --- /dev/null +++ b/src/MemberCenter.Api/Authorization/AdminPermissionAttribute.cs @@ -0,0 +1,40 @@ +using MemberCenter.Application.Abstractions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace MemberCenter.Api.Authorization; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] +public sealed class AdminPermissionAttribute : TypeFilterAttribute +{ + public AdminPermissionAttribute(string permissionKey) + : base(typeof(AdminPermissionFilter)) + { + Arguments = [permissionKey]; + } +} + +public sealed class AdminPermissionFilter : IAsyncAuthorizationFilter +{ + private readonly string _permissionKey; + private readonly IAdminPermissionChecker _permissionChecker; + + public AdminPermissionFilter(string permissionKey, IAdminPermissionChecker permissionChecker) + { + _permissionKey = permissionKey; + _permissionChecker = permissionChecker; + } + + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + var hasPermission = await _permissionChecker.HasPermissionAsync( + context.HttpContext.User, + _permissionKey, + context.HttpContext.RequestAborted); + + if (!hasPermission) + { + context.Result = new NotFoundResult(); + } + } +} diff --git a/src/MemberCenter.Api/Controllers/AdminNewsletterListsController.cs b/src/MemberCenter.Api/Controllers/AdminNewsletterListsController.cs index 67ffc11..39e1b91 100644 --- a/src/MemberCenter.Api/Controllers/AdminNewsletterListsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminNewsletterListsController.cs @@ -1,5 +1,7 @@ +using MemberCenter.Api.Authorization; using MemberCenter.Api.Contracts; using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,6 +10,7 @@ namespace MemberCenter.Api.Controllers; [ApiController] [Route("admin/newsletter-lists")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.NewsletterLists)] public class AdminNewsletterListsController : ControllerBase { private readonly INewsletterListService _listService; @@ -18,6 +21,7 @@ public class AdminNewsletterListsController : ControllerBase } [HttpGet] + [AdminPermission(AdminPermissions.NewsletterListsIndex)] public async Task List() { var lists = await _listService.ListAsync(); @@ -25,6 +29,7 @@ public class AdminNewsletterListsController : ControllerBase } [HttpPost] + [AdminPermission(AdminPermissions.NewsletterListsCreate)] public async Task Create([FromBody] NewsletterListRequest request) { var list = await _listService.CreateAsync(request.TenantId, request.Name, request.Status); @@ -32,6 +37,7 @@ public class AdminNewsletterListsController : ControllerBase } [HttpGet("{id:guid}")] + [AdminPermission(AdminPermissions.NewsletterListsIndex)] public async Task Get(Guid id) { var list = await _listService.GetAsync(id); @@ -44,6 +50,7 @@ public class AdminNewsletterListsController : ControllerBase } [HttpPut("{id:guid}")] + [AdminPermission(AdminPermissions.NewsletterListsEdit)] public async Task Update(Guid id, [FromBody] NewsletterListRequest request) { var list = await _listService.UpdateAsync(id, request.TenantId, request.Name, request.Status); @@ -55,6 +62,7 @@ public class AdminNewsletterListsController : ControllerBase } [HttpDelete("{id:guid}")] + [AdminPermission(AdminPermissions.NewsletterListsDelete)] public async Task Delete(Guid id) { var deleted = await _listService.DeleteAsync(id); diff --git a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs index dd66aac..488931c 100644 --- a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs @@ -1,5 +1,7 @@ +using MemberCenter.Api.Authorization; using MemberCenter.Api.Contracts; using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; @@ -10,6 +12,7 @@ namespace MemberCenter.Api.Controllers; [ApiController] [Route("admin/oauth-clients")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.OAuthClients)] public class AdminOAuthClientsController : ControllerBase { private readonly IOpenIddictApplicationManager _applicationManager; @@ -24,6 +27,7 @@ public class AdminOAuthClientsController : ControllerBase } [HttpGet] + [AdminPermission(AdminPermissions.OAuthClientsIndex)] public async Task List() { var results = new List(); @@ -44,6 +48,7 @@ public class AdminOAuthClientsController : ControllerBase } [HttpPost] + [AdminPermission(AdminPermissions.OAuthClientsCreate)] public async Task Create([FromBody] OAuthClientRequest request) { if (!IsValidUsage(request.Usage)) @@ -103,6 +108,7 @@ public class AdminOAuthClientsController : ControllerBase } [HttpGet("{id}")] + [AdminPermission(AdminPermissions.OAuthClientsIndex)] public async Task Get(string id) { var app = await _applicationManager.FindByIdAsync(id); @@ -123,6 +129,7 @@ public class AdminOAuthClientsController : ControllerBase } [HttpPut("{id}")] + [AdminPermission(AdminPermissions.OAuthClientsEdit)] public async Task Update(string id, [FromBody] OAuthClientRequest request) { if (!IsValidUsage(request.Usage)) @@ -195,6 +202,7 @@ public class AdminOAuthClientsController : ControllerBase } [HttpDelete("{id}")] + [AdminPermission(AdminPermissions.OAuthClientsDelete)] public async Task Delete(string id) { var app = await _applicationManager.FindByIdAsync(id); diff --git a/src/MemberCenter.Api/Controllers/AdminTenantsController.cs b/src/MemberCenter.Api/Controllers/AdminTenantsController.cs index b51093c..31e75a5 100644 --- a/src/MemberCenter.Api/Controllers/AdminTenantsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminTenantsController.cs @@ -1,5 +1,7 @@ +using MemberCenter.Api.Authorization; using MemberCenter.Api.Contracts; using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,6 +10,7 @@ namespace MemberCenter.Api.Controllers; [ApiController] [Route("admin/tenants")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.Tenants)] public class AdminTenantsController : ControllerBase { private readonly ITenantService _tenantService; @@ -18,6 +21,7 @@ public class AdminTenantsController : ControllerBase } [HttpGet] + [AdminPermission(AdminPermissions.TenantsIndex)] public async Task List() { var tenants = await _tenantService.ListAsync(); @@ -25,6 +29,7 @@ public class AdminTenantsController : ControllerBase } [HttpPost] + [AdminPermission(AdminPermissions.TenantsCreate)] public async Task Create([FromBody] TenantRequest request) { var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId); @@ -32,6 +37,7 @@ public class AdminTenantsController : ControllerBase } [HttpGet("{id:guid}")] + [AdminPermission(AdminPermissions.TenantsIndex)] public async Task Get(Guid id) { var tenant = await _tenantService.GetAsync(id); @@ -44,6 +50,7 @@ public class AdminTenantsController : ControllerBase } [HttpPut("{id:guid}")] + [AdminPermission(AdminPermissions.TenantsEdit)] public async Task Update(Guid id, [FromBody] TenantRequest request) { var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId); @@ -55,6 +62,7 @@ public class AdminTenantsController : ControllerBase } [HttpDelete("{id:guid}")] + [AdminPermission(AdminPermissions.TenantsDelete)] public async Task Delete(Guid id) { var deleted = await _tenantService.DeleteAsync(id); diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 46dce14..5977ecd 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -199,6 +199,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(services => services.GetRequiredService()); +builder.Services.AddScoped(services => services.GetRequiredService()); builder.Services.AddHttpContextAccessor(); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); @@ -207,6 +210,7 @@ builder.Services.AddScoped(); await registry.EnsureDefaultsAsync(); } + +static async Task EnsureAdminPermissionDefaultsAsync(IServiceProvider services) +{ + await using var scope = services.CreateAsyncScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.EnsureDefaultsAsync(); +} diff --git a/src/MemberCenter.Application/Abstractions/IAdminPermissionChecker.cs b/src/MemberCenter.Application/Abstractions/IAdminPermissionChecker.cs new file mode 100644 index 0000000..23c3b7c --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IAdminPermissionChecker.cs @@ -0,0 +1,8 @@ +using System.Security.Claims; + +namespace MemberCenter.Application.Abstractions; + +public interface IAdminPermissionChecker +{ + Task HasPermissionAsync(ClaimsPrincipal user, string permissionKey, CancellationToken cancellationToken = default); +} diff --git a/src/MemberCenter.Application/Abstractions/IAdminPermissionSeeder.cs b/src/MemberCenter.Application/Abstractions/IAdminPermissionSeeder.cs new file mode 100644 index 0000000..a893302 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IAdminPermissionSeeder.cs @@ -0,0 +1,6 @@ +namespace MemberCenter.Application.Abstractions; + +public interface IAdminPermissionSeeder +{ + Task EnsureDefaultsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MemberCenter.Application/Constants/AdminPermissions.cs b/src/MemberCenter.Application/Constants/AdminPermissions.cs new file mode 100644 index 0000000..54176c5 --- /dev/null +++ b/src/MemberCenter.Application/Constants/AdminPermissions.cs @@ -0,0 +1,94 @@ +namespace MemberCenter.Application.Constants; + +public static class AdminPermissions +{ + public const string AdminRole = "admin"; + public const string SuperuserRole = "superuser"; + + public const string Home = "admin.home"; + public const string Accounts = "admin.accounts"; + public const string AccountsIndex = "admin.accounts.index"; + public const string AccountsSetAdmin = "admin.accounts.set_admin"; + public const string AccountsSetDisabled = "admin.accounts.set_disabled"; + public const string AccountsResetPassword = "admin.accounts.reset_password"; + public const string Tenants = "admin.tenants"; + public const string TenantsIndex = "admin.tenants.index"; + public const string TenantsCreate = "admin.tenants.create"; + public const string TenantsEdit = "admin.tenants.edit"; + public const string TenantsDelete = "admin.tenants.delete"; + public const string NewsletterLists = "admin.newsletter_lists"; + public const string NewsletterListsIndex = "admin.newsletter_lists.index"; + public const string NewsletterListsCreate = "admin.newsletter_lists.create"; + public const string NewsletterListsEdit = "admin.newsletter_lists.edit"; + public const string NewsletterListsDelete = "admin.newsletter_lists.delete"; + public const string Subscriptions = "admin.subscriptions"; + public const string SubscriptionsIndex = "admin.subscriptions.index"; + public const string SubscriptionsExport = "admin.subscriptions.export"; + public const string OAuthClients = "admin.oauth_clients"; + public const string OAuthClientsIndex = "admin.oauth_clients.index"; + public const string OAuthClientsCreate = "admin.oauth_clients.create"; + public const string OAuthClientsEdit = "admin.oauth_clients.edit"; + public const string OAuthClientsDelete = "admin.oauth_clients.delete"; + public const string OAuthClientsRotateSecret = "admin.oauth_clients.rotate_secret"; + public const string AuditLogs = "admin.audit_logs"; + public const string AuditLogsIndex = "admin.audit_logs.index"; + public const string Security = "admin.security"; + public const string SecurityIndex = "admin.security.index"; + public const string SecuritySave = "admin.security.save"; + public const string SecurityTestEmail = "admin.security.test_email"; + public const string Blacklist = "admin.blacklist"; + public const string BlacklistIndex = "admin.blacklist.index"; + public const string BlacklistCreate = "admin.blacklist.create"; + + public static readonly IReadOnlyList All = + [ + new(Home, "Overview", "Open the admin overview."), + + new(Accounts, "Accounts", "Open account governance."), + new(AccountsIndex, "Accounts / View", "View account governance."), + new(AccountsSetAdmin, "Accounts / Grant admin", "Grant or remove admin role.", SuperuserOnly: true), + new(AccountsSetDisabled, "Accounts / Disable account", "Disable or enable member accounts.", SuperuserOnly: true), + new(AccountsResetPassword, "Accounts / Reset password", "Reset member passwords.", SuperuserOnly: true), + + new(Tenants, "Tenants", "Open tenant management."), + new(TenantsIndex, "Tenants / View", "View tenants."), + new(TenantsCreate, "Tenants / Create", "Create tenants."), + new(TenantsEdit, "Tenants / Edit", "Edit tenants."), + new(TenantsDelete, "Tenants / Delete", "Delete tenants."), + + new(NewsletterLists, "Newsletter lists", "Open newsletter list management."), + new(NewsletterListsIndex, "Newsletter lists / View", "View newsletter lists."), + new(NewsletterListsCreate, "Newsletter lists / Create", "Create newsletter lists."), + new(NewsletterListsEdit, "Newsletter lists / Edit", "Edit newsletter lists."), + new(NewsletterListsDelete, "Newsletter lists / Delete", "Delete newsletter lists."), + + new(Subscriptions, "Subscriptions", "Open subscription management."), + new(SubscriptionsIndex, "Subscriptions / View", "View subscriptions."), + new(SubscriptionsExport, "Subscriptions / Export", "Export subscriptions."), + + new(OAuthClients, "OAuth clients", "Open OAuth client management."), + new(OAuthClientsIndex, "OAuth clients / View", "View OAuth clients."), + new(OAuthClientsCreate, "OAuth clients / Create", "Create OAuth clients."), + new(OAuthClientsEdit, "OAuth clients / Edit", "Edit OAuth clients."), + new(OAuthClientsDelete, "OAuth clients / Delete", "Delete OAuth clients."), + new(OAuthClientsRotateSecret, "OAuth clients / Rotate secret", "Rotate OAuth client secrets."), + + new(AuditLogs, "Audit logs", "Open audit logs."), + new(AuditLogsIndex, "Audit logs / View", "View audit logs."), + + new(Security, "Security", "Open security settings."), + new(SecurityIndex, "Security / View", "View security settings."), + new(SecuritySave, "Security / Save", "Save security settings."), + new(SecurityTestEmail, "Security / Test email", "Send security settings test email."), + + new(Blacklist, "Blacklist", "Open email blacklist."), + new(BlacklistIndex, "Blacklist / View", "View email blacklist."), + new(BlacklistCreate, "Blacklist / Create", "Add or update email blacklist entries.") + ]; +} + +public sealed record AdminPermissionDefinition( + string Key, + string Name, + string Description, + bool SuperuserOnly = false); diff --git a/src/MemberCenter.Domain/Entities/AdminPermission.cs b/src/MemberCenter.Domain/Entities/AdminPermission.cs new file mode 100644 index 0000000..43f6cfc --- /dev/null +++ b/src/MemberCenter.Domain/Entities/AdminPermission.cs @@ -0,0 +1,14 @@ +namespace MemberCenter.Domain.Entities; + +public sealed class AdminPermission +{ + public Guid Id { get; set; } + public string Key { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsEnabled { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public List RolePermissions { get; set; } = new(); +} diff --git a/src/MemberCenter.Domain/Entities/AdminRolePermission.cs b/src/MemberCenter.Domain/Entities/AdminRolePermission.cs new file mode 100644 index 0000000..3d5e051 --- /dev/null +++ b/src/MemberCenter.Domain/Entities/AdminRolePermission.cs @@ -0,0 +1,10 @@ +namespace MemberCenter.Domain.Entities; + +public sealed class AdminRolePermission +{ + public Guid RoleId { get; set; } + public Guid PermissionId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public AdminPermission? Permission { get; set; } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs index 5db34ce..679c988 100644 --- a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs +++ b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs @@ -27,6 +27,8 @@ public class MemberCenterDbContext public DbSet AuthResources => Set(); public DbSet AuthResourceScopes => Set(); public DbSet AuthClientUsagePermissions => Set(); + public DbSet AdminPermissions => Set(); + public DbSet AdminRolePermissions => Set(); public DbSet FileAccessDownloadTokens => Set(); public DbSet DataProtectionKeys { get; set; } = null!; @@ -248,6 +250,34 @@ public class MemberCenterDbContext entity.HasIndex(x => new { x.Usage, x.Scope }).IsUnique(); }); + builder.Entity(entity => + { + entity.ToTable("admin_permissions"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Key).IsRequired().HasMaxLength(200); + entity.Property(x => x.Name).IsRequired().HasMaxLength(200); + entity.Property(x => x.Description).HasMaxLength(1000); + entity.Property(x => x.IsEnabled).HasDefaultValue(true); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()"); + entity.HasIndex(x => x.Key).IsUnique(); + }); + + builder.Entity(entity => + { + entity.ToTable("admin_role_permissions"); + entity.HasKey(x => new { x.RoleId, x.PermissionId }); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.RoleId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Permission) + .WithMany(x => x.RolePermissions) + .HasForeignKey(x => x.PermissionId) + .OnDelete(DeleteBehavior.Cascade); + }); + builder.Entity(entity => { entity.ToTable("file_access_download_tokens"); diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260703055656_AddAdminPermissions.Designer.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260703055656_AddAdminPermissions.Designer.cs new file mode 100644 index 0000000..6dd743d --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260703055656_AddAdminPermissions.Designer.cs @@ -0,0 +1,1406 @@ +// +using System; +using System.Collections.Generic; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260703055656_AddAdminPermissions")] + partial class AddAdminPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AdminPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("admin_permissions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AdminRolePermission", b => + { + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("PermissionId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("RoleId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("admin_role_permissions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthClientUsagePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Usage") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Usage", "Scope") + .IsUnique(); + + b.ToTable("auth_client_usage_permissions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowDelegatedToken") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Audience") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequireTenant") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Audience") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("auth_resources", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ResourceId") + .HasColumnType("uuid"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("idx_auth_resource_scopes_scope"); + + b.HasIndex("ResourceId", "Scope") + .IsUnique(); + + b.ToTable("auth_resource_scopes", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlacklistedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("BlacklistedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("email_blacklist", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_email_verifications_email"); + + b.HasIndex("TenantId"); + + b.ToTable("email_verifications", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IssuedByClientId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastValidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("GET"); + + b.Property("ObjectKey") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasDefaultValue("files:download.read"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("idx_file_access_download_tokens_expires_at"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId") + .HasDatabaseName("idx_file_access_download_tokens_tenant_user"); + + b.ToTable("file_access_download_tokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("newsletter_lists", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ListId") + .HasColumnType("uuid"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_newsletter_subscriptions_email"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_newsletter_subscriptions_list_id"); + + b.HasIndex("UserId"); + + b.HasIndex("ListId", "Email") + .IsUnique(); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.SystemFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("system_flags", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property>("Domains") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("unsubscribe_tokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLine2") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressMetaJson") + .HasColumnType("jsonb"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("District") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RecipientName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RecipientPhone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StateRegion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Usage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("shipping"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .HasDatabaseName("idx_user_addresses_user_id"); + + b.HasIndex("UserId", "IsDefault") + .IsUnique() + .HasDatabaseName("ux_user_addresses_default_per_user") + .HasFilter("\"IsDefault\" = true"); + + b.HasIndex("UserId", "Usage") + .HasDatabaseName("idx_user_addresses_user_id_usage"); + + b.ToTable("user_addresses", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Department") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("unspecified"); + + b.Property("InvoiceTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("JobTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LandlinePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MobilePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NickName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Remark") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TaxId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("UserId"); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("BlacklistedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BlacklistedBy") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlacklisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AdminRolePermission", b => + { + b.HasOne("MemberCenter.Domain.Entities.AdminPermission", "Permission") + .WithMany("RolePermissions") + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.HasOne("MemberCenter.Domain.Entities.AuthResource", "Resource") + .WithMany("Scopes") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resource"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant") + .WithMany("NewsletterLists") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterList", "List") + .WithMany("Subscriptions") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("List"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterSubscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany("Addresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithOne("Profile") + .HasForeignKey("MemberCenter.Domain.Entities.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AdminPermission", b => + { + b.Navigation("RolePermissions"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Navigation("Scopes"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Navigation("NewsletterLists"); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Navigation("Addresses"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260703055656_AddAdminPermissions.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260703055656_AddAdminPermissions.cs new file mode 100644 index 0000000..0933287 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260703055656_AddAdminPermissions.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAdminPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "admin_permissions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + IsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_admin_permissions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "admin_role_permissions", + columns: table => new + { + RoleId = table.Column(type: "uuid", nullable: false), + PermissionId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_admin_role_permissions", x => new { x.RoleId, x.PermissionId }); + table.ForeignKey( + name: "FK_admin_role_permissions_admin_permissions_PermissionId", + column: x => x.PermissionId, + principalTable: "admin_permissions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_admin_role_permissions_roles_RoleId", + column: x => x.RoleId, + principalTable: "roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_admin_permissions_Key", + table: "admin_permissions", + column: "Key", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_admin_role_permissions_PermissionId", + table: "admin_role_permissions", + column: "PermissionId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "admin_role_permissions"); + + migrationBuilder.DropTable( + name: "admin_permissions"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs index 6faf3e2..d0cfc64 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs @@ -23,6 +23,69 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("MemberCenter.Domain.Entities.AdminPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("admin_permissions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AdminRolePermission", b => + { + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("PermissionId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("RoleId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("admin_role_permissions", (string)null); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.AuditLog", b => { b.Property("Id") @@ -1113,6 +1176,23 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("OpenIddictTokens", (string)null); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.AdminRolePermission", b => + { + b.HasOne("MemberCenter.Domain.Entities.AdminPermission", "Permission") + .WithMany("RolePermissions") + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => { b.HasOne("MemberCenter.Domain.Entities.AuthResource", "Resource") @@ -1279,6 +1359,11 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.Navigation("Authorization"); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.AdminPermission", b => + { + b.Navigation("RolePermissions"); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => { b.Navigation("Scopes"); diff --git a/src/MemberCenter.Infrastructure/Services/AdminPermissionService.cs b/src/MemberCenter.Infrastructure/Services/AdminPermissionService.cs new file mode 100644 index 0000000..eba5e5d --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/AdminPermissionService.cs @@ -0,0 +1,151 @@ +using System.Security.Claims; +using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; +using MemberCenter.Domain.Entities; +using MemberCenter.Infrastructure.Identity; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace MemberCenter.Infrastructure.Services; + +public sealed class AdminPermissionService : IAdminPermissionChecker, IAdminPermissionSeeder +{ + private readonly MemberCenterDbContext _dbContext; + private readonly RoleManager _roleManager; + private Task>? _effectivePermissions; + + public AdminPermissionService(MemberCenterDbContext dbContext, RoleManager roleManager) + { + _dbContext = dbContext; + _roleManager = roleManager; + } + + public async Task HasPermissionAsync( + ClaimsPrincipal user, + string permissionKey, + CancellationToken cancellationToken = default) + { + if (user.Identity?.IsAuthenticated != true) + { + return false; + } + + if (user.IsInRole(AdminPermissions.SuperuserRole)) + { + return true; + } + + var roleNames = user.FindAll(ClaimTypes.Role) + .Select(claim => claim.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.ToUpperInvariant()) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (roleNames.Count == 0) + { + return false; + } + + _effectivePermissions ??= LoadEffectivePermissionsAsync(roleNames, cancellationToken); + var effectivePermissions = await _effectivePermissions; + return effectivePermissions.Contains(permissionKey); + } + + private async Task> LoadEffectivePermissionsAsync( + IReadOnlyCollection normalizedRoleNames, + CancellationToken cancellationToken) + { + var permissions = await _dbContext.AdminRolePermissions + .AsNoTracking() + .Where(rolePermission => rolePermission.Permission != null && rolePermission.Permission.IsEnabled) + .Join( + _dbContext.Roles.AsNoTracking(), + rolePermission => rolePermission.RoleId, + role => role.Id, + (rolePermission, role) => new { rolePermission.Permission!.Key, role.NormalizedName }) + .Where(item => item.NormalizedName != null && normalizedRoleNames.Contains(item.NormalizedName)) + .Select(item => item.Key) + .Distinct() + .ToListAsync(cancellationToken); + + return permissions.ToHashSet(StringComparer.Ordinal); + } + + public async Task EnsureDefaultsAsync(CancellationToken cancellationToken = default) + { + var permissionsByKey = await _dbContext.AdminPermissions + .ToDictionaryAsync(permission => permission.Key, StringComparer.Ordinal, cancellationToken); + + foreach (var definition in AdminPermissions.All) + { + if (!permissionsByKey.TryGetValue(definition.Key, out var permission)) + { + permission = new AdminPermission + { + Id = Guid.NewGuid(), + Key = definition.Key + }; + _dbContext.AdminPermissions.Add(permission); + permissionsByKey[definition.Key] = permission; + } + + permission.Name = definition.Name; + permission.Description = definition.Description; + permission.IsEnabled = true; + permission.UpdatedAt = DateTimeOffset.UtcNow; + } + + await EnsureRoleExistsAsync(AdminPermissions.AdminRole); + await EnsureRoleExistsAsync(AdminPermissions.SuperuserRole); + + await _dbContext.SaveChangesAsync(cancellationToken); + + var adminRole = await _roleManager.FindByNameAsync(AdminPermissions.AdminRole) + ?? throw new InvalidOperationException("Admin role was not created."); + + var currentPermissionIds = (await _dbContext.AdminRolePermissions + .Where(rolePermission => rolePermission.RoleId == adminRole.Id) + .Select(rolePermission => rolePermission.PermissionId) + .ToListAsync(cancellationToken)) + .ToHashSet(); + + foreach (var definition in AdminPermissions.All.Where(permission => !permission.SuperuserOnly)) + { + var permission = permissionsByKey[definition.Key]; + if (currentPermissionIds.Contains(permission.Id)) + { + continue; + } + + _dbContext.AdminRolePermissions.Add(new AdminRolePermission + { + RoleId = adminRole.Id, + PermissionId = permission.Id + }); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task EnsureRoleExistsAsync(string roleName) + { + if (await _roleManager.RoleExistsAsync(roleName)) + { + return; + } + + var result = await _roleManager.CreateAsync(new ApplicationRole + { + Id = Guid.NewGuid(), + Name = roleName, + NormalizedName = roleName.ToUpperInvariant() + }); + + if (!result.Succeeded) + { + throw new InvalidOperationException(string.Join("; ", result.Errors.Select(error => error.Description))); + } + } +} diff --git a/src/MemberCenter.Installer/Program.cs b/src/MemberCenter.Installer/Program.cs index 45c71b1..0b462b2 100644 --- a/src/MemberCenter.Installer/Program.cs +++ b/src/MemberCenter.Installer/Program.cs @@ -104,6 +104,8 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo await db.Database.MigrateAsync(); var registry = scope.ServiceProvider.GetRequiredService(); await registry.EnsureDefaultsAsync(); + var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService(); + await adminPermissionSeeder.EnsureDefaultsAsync(); await EnsureRoleAsync(roleManager, "superuser"); await EnsureRoleAsync(roleManager, "admin"); @@ -274,6 +276,8 @@ migrateCommand.SetHandler(async (string? connectionString, string? appsettings, await db.Database.MigrateAsync(); var registry = scope.ServiceProvider.GetRequiredService(); await registry.EnsureDefaultsAsync(); + var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService(); + await adminPermissionSeeder.EnsureDefaultsAsync(); } else { @@ -300,6 +304,8 @@ syncOAuthClientsCommand.SetHandler(async (string? connectionString, string? apps await using var scope = services.CreateAsyncScope(); var registry = scope.ServiceProvider.GetRequiredService(); await registry.EnsureDefaultsAsync(); + var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService(); + await adminPermissionSeeder.EnsureDefaultsAsync(); var applicationManager = scope.ServiceProvider.GetRequiredService(); var applications = new List(); @@ -374,6 +380,9 @@ static IServiceProvider BuildServices(string connectionString) .AddDefaultTokenProviders(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); return services.BuildServiceProvider(); } diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs index 309111a..866e8f3 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs @@ -1,6 +1,8 @@ using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; using MemberCenter.Infrastructure.Identity; using MemberCenter.Web.Areas.Admin.Models; +using MemberCenter.Web.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -9,6 +11,7 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Area("Admin")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.Accounts)] [Route("admin/accounts")] public class AccountsController : Controller { @@ -24,6 +27,7 @@ public class AccountsController : Controller } [HttpGet("")] + [AdminPermission(AdminPermissions.AccountsIndex)] public async Task Index(string? search = null, string? role = null, string? status = null, string? verified = null) { var items = await _accountGovernanceService.ListUsersAsync(search); @@ -42,6 +46,7 @@ public class AccountsController : Controller [HttpPost("{id:guid}/admin")] [Authorize(Policy = "Superuser")] + [AdminPermission(AdminPermissions.AccountsSetAdmin)] [ValidateAntiForgeryToken] public async Task SetAdmin(Guid id, bool enabled, string? search = null, string? role = null, string? status = null, string? verified = null) { @@ -66,6 +71,7 @@ public class AccountsController : Controller [HttpPost("{id:guid}/disabled")] [Authorize(Policy = "Superuser")] + [AdminPermission(AdminPermissions.AccountsSetDisabled)] [ValidateAntiForgeryToken] public async Task SetDisabled(Guid id, bool disabled, string? search = null, string? role = null, string? status = null, string? verified = null) { @@ -90,6 +96,7 @@ public class AccountsController : Controller [HttpPost("{id:guid}/password-reset")] [Authorize(Policy = "Superuser")] + [AdminPermission(AdminPermissions.AccountsResetPassword)] [ValidateAntiForgeryToken] public async Task ResetPassword(Guid id, string newPassword, string? search = null, string? role = null, string? status = null, string? verified = null) { diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/AuditLogsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/AuditLogsController.cs index ee5e262..b62a7cf 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/AuditLogsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/AuditLogsController.cs @@ -1,4 +1,6 @@ using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; +using MemberCenter.Web.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -6,6 +8,7 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Area("Admin")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.AuditLogs)] [Route("admin/audit-logs")] public class AuditLogsController : Controller { @@ -17,6 +20,7 @@ public class AuditLogsController : Controller } [HttpGet("")] + [AdminPermission(AdminPermissions.AuditLogsIndex)] public async Task Index() { var logs = await _auditLogService.ListAsync(); diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/BlacklistController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/BlacklistController.cs index af12ab1..4d3e8dc 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/BlacklistController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/BlacklistController.cs @@ -1,4 +1,6 @@ using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; +using MemberCenter.Web.Authorization; using MemberCenter.Web.Models.Admin; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +9,7 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Area("Admin")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.Blacklist)] [Route("admin/blacklist")] public class BlacklistController : Controller { @@ -18,6 +21,7 @@ public class BlacklistController : Controller } [HttpGet("")] + [AdminPermission(AdminPermissions.BlacklistIndex)] public async Task Index() { var items = await _blacklistService.ListAsync(); @@ -25,12 +29,14 @@ public class BlacklistController : Controller } [HttpGet("create")] + [AdminPermission(AdminPermissions.BlacklistCreate)] public IActionResult Create() { return View(new EmailBlacklistFormViewModel()); } [HttpPost("create")] + [AdminPermission(AdminPermissions.BlacklistCreate)] public async Task Create(EmailBlacklistFormViewModel model) { if (!ModelState.IsValid) diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs index d99fae2..23e72fb 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs @@ -1,3 +1,5 @@ +using MemberCenter.Application.Constants; +using MemberCenter.Web.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -5,10 +7,12 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Area("Admin")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.Home)] [Route("admin")] public sealed class HomeController : Controller { [HttpGet("")] + [AdminPermission(AdminPermissions.Home)] public IActionResult Index() { return View(); diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs index b974943..eb19958 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs @@ -1,4 +1,6 @@ using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; +using MemberCenter.Web.Authorization; using MemberCenter.Web.Models.Admin; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +9,7 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Area("Admin")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.NewsletterLists)] [Route("admin/newsletter-lists")] public class NewsletterListsController : Controller { @@ -20,6 +23,7 @@ public class NewsletterListsController : Controller } [HttpGet("")] + [AdminPermission(AdminPermissions.NewsletterListsIndex)] public async Task Index() { var lists = await _listService.ListAsync(); @@ -27,6 +31,7 @@ public class NewsletterListsController : Controller } [HttpGet("create")] + [AdminPermission(AdminPermissions.NewsletterListsCreate)] public async Task Create() { var tenants = await _tenantService.ListAsync(); @@ -37,6 +42,7 @@ public class NewsletterListsController : Controller } [HttpPost("create")] + [AdminPermission(AdminPermissions.NewsletterListsCreate)] public async Task Create(NewsletterListFormViewModel model) { if (!ModelState.IsValid) @@ -50,6 +56,7 @@ public class NewsletterListsController : Controller } [HttpGet("edit/{id:guid}")] + [AdminPermission(AdminPermissions.NewsletterListsEdit)] public async Task Edit(Guid id) { var list = await _listService.GetAsync(id); @@ -70,6 +77,7 @@ public class NewsletterListsController : Controller } [HttpPost("edit/{id:guid}")] + [AdminPermission(AdminPermissions.NewsletterListsEdit)] public async Task Edit(Guid id, NewsletterListFormViewModel model) { if (!ModelState.IsValid) @@ -88,6 +96,7 @@ public class NewsletterListsController : Controller } [HttpPost("delete/{id:guid}")] + [AdminPermission(AdminPermissions.NewsletterListsDelete)] public async Task Delete(Guid id) { await _listService.DeleteAsync(id); diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs index 5f07b71..11f5d30 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs @@ -1,4 +1,6 @@ using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; +using MemberCenter.Web.Authorization; using MemberCenter.Web.Models.Admin; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,6 +10,7 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Area("Admin")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.OAuthClients)] [Route("admin/oauth-clients")] public class OAuthClientsController : Controller { @@ -26,6 +29,7 @@ public class OAuthClientsController : Controller } [HttpGet("")] + [AdminPermission(AdminPermissions.OAuthClientsIndex)] public async Task Index() { var results = new List(); @@ -51,6 +55,7 @@ public class OAuthClientsController : Controller } [HttpGet("create")] + [AdminPermission(AdminPermissions.OAuthClientsCreate)] public async Task Create() { var tenants = await _tenantService.ListAsync(); @@ -61,6 +66,7 @@ public class OAuthClientsController : Controller } [HttpPost("create")] + [AdminPermission(AdminPermissions.OAuthClientsCreate)] public async Task Create(OAuthClientFormViewModel model) { if (!IsValidUsage(model.Usage)) @@ -131,6 +137,7 @@ public class OAuthClientsController : Controller } [HttpGet("edit/{id}")] + [AdminPermission(AdminPermissions.OAuthClientsEdit)] public async Task Edit(string id) { var app = await _applicationManager.FindByIdAsync(id); @@ -158,6 +165,7 @@ public class OAuthClientsController : Controller } [HttpPost("edit/{id}")] + [AdminPermission(AdminPermissions.OAuthClientsEdit)] public async Task Edit(string id, OAuthClientFormViewModel model) { if (!IsValidUsage(model.Usage)) @@ -232,6 +240,7 @@ public class OAuthClientsController : Controller } [HttpPost("delete/{id}")] + [AdminPermission(AdminPermissions.OAuthClientsDelete)] public async Task Delete(string id) { var app = await _applicationManager.FindByIdAsync(id); @@ -245,6 +254,7 @@ public class OAuthClientsController : Controller } [HttpPost("rotate-secret/{id}")] + [AdminPermission(AdminPermissions.OAuthClientsRotateSecret)] public async Task RotateSecret(string id) { var app = await _applicationManager.FindByIdAsync(id); diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs index d1d1910..38abd23 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs @@ -1,6 +1,8 @@ using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; using MemberCenter.Application.Models.Admin; using MemberCenter.Infrastructure.Identity; +using MemberCenter.Web.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -10,6 +12,7 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Area("Admin")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.Security)] [Route("admin/security")] public class SecurityController : Controller { @@ -23,6 +26,7 @@ public class SecurityController : Controller } [HttpGet("")] + [AdminPermission(AdminPermissions.SecurityIndex)] public async Task Index() { var settings = await _settingsService.GetAsync(); @@ -30,6 +34,7 @@ public class SecurityController : Controller } [HttpPost("")] + [AdminPermission(AdminPermissions.SecuritySave)] public async Task Save(SecuritySettingsDto model) { if (!ModelState.IsValid) @@ -52,6 +57,7 @@ public class SecurityController : Controller } [HttpPost("test-email")] + [AdminPermission(AdminPermissions.SecurityTestEmail)] [ValidateAntiForgeryToken] public async Task TestEmail(SecuritySettingsDto model) { diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/SubscriptionsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/SubscriptionsController.cs index d6098b3..d38cfbe 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/SubscriptionsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/SubscriptionsController.cs @@ -1,4 +1,6 @@ using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; +using MemberCenter.Web.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Text; @@ -7,6 +9,7 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Area("Admin")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.Subscriptions)] [Route("admin/subscriptions")] public class SubscriptionsController : Controller { @@ -18,6 +21,7 @@ public class SubscriptionsController : Controller } [HttpGet("")] + [AdminPermission(AdminPermissions.SubscriptionsIndex)] public async Task Index() { var subscriptions = await _subscriptionService.ListAsync(); @@ -25,6 +29,7 @@ public class SubscriptionsController : Controller } [HttpGet("export")] + [AdminPermission(AdminPermissions.SubscriptionsExport)] public async Task Export() { var subscriptions = await _subscriptionService.ListAsync(); diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs index 9eda581..df63c9b 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs @@ -1,4 +1,6 @@ using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; +using MemberCenter.Web.Authorization; using MemberCenter.Web.Models.Admin; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,6 +9,7 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Area("Admin")] [Authorize(Policy = "Admin")] +[AdminPermission(AdminPermissions.Tenants)] [Route("admin/tenants")] public class TenantsController : Controller { @@ -18,6 +21,7 @@ public class TenantsController : Controller } [HttpGet("")] + [AdminPermission(AdminPermissions.TenantsIndex)] public async Task Index() { var tenants = await _tenantService.ListAsync(); @@ -25,12 +29,14 @@ public class TenantsController : Controller } [HttpGet("create")] + [AdminPermission(AdminPermissions.TenantsCreate)] public IActionResult Create() { return View(new TenantFormViewModel()); } [HttpPost("create")] + [AdminPermission(AdminPermissions.TenantsCreate)] public async Task Create(TenantFormViewModel model) { if (!ModelState.IsValid) @@ -50,6 +56,7 @@ public class TenantsController : Controller } [HttpGet("edit/{id:guid}")] + [AdminPermission(AdminPermissions.TenantsEdit)] public async Task Edit(Guid id) { var tenant = await _tenantService.GetAsync(id); @@ -69,6 +76,7 @@ public class TenantsController : Controller } [HttpPost("edit/{id:guid}")] + [AdminPermission(AdminPermissions.TenantsEdit)] public async Task Edit(Guid id, TenantFormViewModel model) { if (!ModelState.IsValid) @@ -93,6 +101,7 @@ public class TenantsController : Controller } [HttpPost("delete/{id:guid}")] + [AdminPermission(AdminPermissions.TenantsDelete)] public async Task Delete(Guid id) { await _tenantService.DeleteAsync(id); diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml index bc119d0..4dd4f37 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml @@ -1,7 +1,10 @@ @model IReadOnlyList

Email Blacklist

-

Add

+@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.BlacklistCreate)) +{ +

Add

+} diff --git a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml index c6bbcdc..f22694c 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml @@ -1,7 +1,10 @@ @model IReadOnlyList

Newsletter Lists

-

Create

+@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsCreate)) +{ +

Create

+}
EmailReasonByAt
@@ -15,10 +18,16 @@ } diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml index e2010ac..e753dbe 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml @@ -1,7 +1,10 @@ @model IReadOnlyList

OAuth Clients

-

Create

+@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsCreate)) +{ +

Create

+} @if (TempData["CreatedClientId"] is string createdId) {
@@ -42,16 +45,23 @@
} diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml index 94536fa..130c9bc 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml @@ -49,9 +49,14 @@ - + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecuritySave)) + { + + } +@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecurityTestEmail)) +{

Test Email

@Html.AntiForgeryToken() @@ -72,3 +77,4 @@ +} diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml index 6ee5c69..905c080 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml @@ -1,3 +1,6 @@ +@using MemberCenter.Application.Constants +@using MemberCenter.Application.Abstractions +@inject IAdminPermissionChecker AdminPermissionChecker @@ -36,15 +39,24 @@

Lightweight structure for operations screens. Visual design can be replaced later.

diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml index 4f57752..2b1551e 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml @@ -1,7 +1,10 @@ @model IReadOnlyList

Subscriptions

-

Export CSV

+@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SubscriptionsExport)) +{ +

Export CSV

+}
List IDNameTenantStatus
@(string.IsNullOrWhiteSpace(list.TenantName) ? list.TenantId.ToString() : list.TenantName) @list.Status - Edit -
- -
+ @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsEdit)) + { + Edit + } + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsDelete)) + { +
+ +
+ }
@clientType @usage - Edit -@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase)) -{ -
- -
-} -
- -
+ @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsEdit)) + { + Edit + } + @if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase) + && await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsRotateSecret)) + { +
+ +
+ } + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsDelete)) + { +
+ +
+ }
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml index 87747fe..eaae04d 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml @@ -1,7 +1,10 @@ @model IReadOnlyList

Tenants

-

Create

+@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsCreate)) +{ +

Create

+}
EmailListStatusCreated
@@ -16,10 +19,16 @@ } diff --git a/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml index d2d0d15..d5e0bed 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml @@ -1,3 +1,6 @@ @using MemberCenter.Web @using MemberCenter.Web.Models +@using MemberCenter.Application.Constants +@using MemberCenter.Application.Abstractions +@inject IAdminPermissionChecker AdminPermissionChecker @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/MemberCenter.Web/Authorization/AdminPermissionAttribute.cs b/src/MemberCenter.Web/Authorization/AdminPermissionAttribute.cs new file mode 100644 index 0000000..6482419 --- /dev/null +++ b/src/MemberCenter.Web/Authorization/AdminPermissionAttribute.cs @@ -0,0 +1,40 @@ +using MemberCenter.Application.Abstractions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace MemberCenter.Web.Authorization; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] +public sealed class AdminPermissionAttribute : TypeFilterAttribute +{ + public AdminPermissionAttribute(string permissionKey) + : base(typeof(AdminPermissionFilter)) + { + Arguments = [permissionKey]; + } +} + +public sealed class AdminPermissionFilter : IAsyncAuthorizationFilter +{ + private readonly string _permissionKey; + private readonly IAdminPermissionChecker _permissionChecker; + + public AdminPermissionFilter(string permissionKey, IAdminPermissionChecker permissionChecker) + { + _permissionKey = permissionKey; + _permissionChecker = permissionChecker; + } + + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + var hasPermission = await _permissionChecker.HasPermissionAsync( + context.HttpContext.User, + _permissionKey, + context.HttpContext.RequestAborted); + + if (!hasPermission) + { + context.Result = new NotFoundResult(); + } + } +} diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 347014b..de118db 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -135,6 +135,9 @@ builder.Services.AddScoped( builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(services => services.GetRequiredService()); +builder.Services.AddScoped(services => services.GetRequiredService()); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); builder.Services.AddScoped(); @@ -155,6 +158,7 @@ builder.Services.AddHttpContextAccessor(); var app = builder.Build(); await EnsureAuthRegistryDefaultsAsync(app.Services); +await EnsureAdminPermissionDefaultsAsync(app.Services); if (!app.Environment.IsDevelopment()) { @@ -244,3 +248,10 @@ static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services) var registry = scope.ServiceProvider.GetRequiredService(); await registry.EnsureDefaultsAsync(); } + +static async Task EnsureAdminPermissionDefaultsAsync(IServiceProvider services) +{ + await using var scope = services.CreateAsyncScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.EnsureDefaultsAsync(); +} diff --git a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml index 42d009b..c9499c4 100644 --- a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml @@ -1,3 +1,6 @@ +@using MemberCenter.Application.Constants +@using MemberCenter.Application.Abstractions +@inject IAdminPermissionChecker AdminPermissionChecker @@ -40,20 +43,29 @@ Subscriptions - @if (User.IsInRole("admin") || User.IsInRole("superuser")) + @if (User.IsInRole(AdminPermissions.AdminRole) || User.IsInRole(AdminPermissions.SuperuserRole)) {
Admin
- Overview - Accounts - Tenants - Newsletter Lists - Subscriptions - OAuth Clients - Audit Logs - Security - Blacklist + @foreach (var item in new[] + { + new { Permission = AdminPermissions.Home, Controller = "Home", Label = "Overview" }, + new { Permission = AdminPermissions.AccountsIndex, Controller = "Accounts", Label = "Accounts" }, + new { Permission = AdminPermissions.TenantsIndex, Controller = "Tenants", Label = "Tenants" }, + new { Permission = AdminPermissions.NewsletterListsIndex, Controller = "NewsletterLists", Label = "Newsletter Lists" }, + new { Permission = AdminPermissions.SubscriptionsIndex, Controller = "Subscriptions", Label = "Subscriptions" }, + new { Permission = AdminPermissions.OAuthClientsIndex, Controller = "OAuthClients", Label = "OAuth Clients" }, + new { Permission = AdminPermissions.AuditLogsIndex, Controller = "AuditLogs", Label = "Audit Logs" }, + new { Permission = AdminPermissions.SecurityIndex, Controller = "Security", Label = "Security" }, + new { Permission = AdminPermissions.BlacklistIndex, Controller = "Blacklist", Label = "Blacklist" } + }) + { + if (await AdminPermissionChecker.HasPermissionAsync(User, item.Permission)) + { + @item.Label + } + }
}
NameTenant IdWebhook Client IdDomainsStatus
@string.Join(",", tenant.Domains) @tenant.Status - Edit -
- -
+ @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsEdit)) + { + Edit + } + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsDelete)) + { +
+ +
+ }