Add admin permission checks to admin controllers

This commit is contained in:
Warren Chen 2026-07-03 15:42:49 +09:00
parent fedb011154
commit 2eff12d903
38 changed files with 2278 additions and 43 deletions

View File

@ -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
## 專案結構

View File

@ -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.

View File

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

View File

@ -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<IActionResult> List()
{
var lists = await _listService.ListAsync();
@ -25,6 +29,7 @@ public class AdminNewsletterListsController : ControllerBase
}
[HttpPost]
[AdminPermission(AdminPermissions.NewsletterListsCreate)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id)
{
var deleted = await _listService.DeleteAsync(id);

View File

@ -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<IActionResult> List()
{
var results = new List<object>();
@ -44,6 +48,7 @@ public class AdminOAuthClientsController : ControllerBase
}
[HttpPost]
[AdminPermission(AdminPermissions.OAuthClientsCreate)]
public async Task<IActionResult> Create([FromBody] OAuthClientRequest request)
{
if (!IsValidUsage(request.Usage))
@ -103,6 +108,7 @@ public class AdminOAuthClientsController : ControllerBase
}
[HttpGet("{id}")]
[AdminPermission(AdminPermissions.OAuthClientsIndex)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Delete(string id)
{
var app = await _applicationManager.FindByIdAsync(id);

View File

@ -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<IActionResult> List()
{
var tenants = await _tenantService.ListAsync();
@ -25,6 +29,7 @@ public class AdminTenantsController : ControllerBase
}
[HttpPost]
[AdminPermission(AdminPermissions.TenantsCreate)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id)
{
var deleted = await _tenantService.DeleteAsync(id);

View File

@ -199,6 +199,9 @@ builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
builder.Services.AddScoped<AdminPermissionService>();
builder.Services.AddScoped<IAdminPermissionChecker>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.AddScoped<IAdminPermissionSeeder>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
@ -207,6 +210,7 @@ builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublish
var app = builder.Build();
await EnsureAuthRegistryDefaultsAsync(app.Services);
await EnsureAdminPermissionDefaultsAsync(app.Services);
app.UseForwardedHeaders();
if (!string.IsNullOrWhiteSpace(pathBase))
@ -342,3 +346,10 @@ static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services)
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
}
static async Task EnsureAdminPermissionDefaultsAsync(IServiceProvider services)
{
await using var scope = services.CreateAsyncScope();
var seeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
await seeder.EnsureDefaultsAsync();
}

View File

@ -0,0 +1,8 @@
using System.Security.Claims;
namespace MemberCenter.Application.Abstractions;
public interface IAdminPermissionChecker
{
Task<bool> HasPermissionAsync(ClaimsPrincipal user, string permissionKey, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,6 @@
namespace MemberCenter.Application.Abstractions;
public interface IAdminPermissionSeeder
{
Task EnsureDefaultsAsync(CancellationToken cancellationToken = default);
}

View File

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

View File

@ -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<AdminRolePermission> RolePermissions { get; set; } = new();
}

View File

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

View File

@ -27,6 +27,8 @@ public class MemberCenterDbContext
public DbSet<AuthResource> AuthResources => Set<AuthResource>();
public DbSet<AuthResourceScope> AuthResourceScopes => Set<AuthResourceScope>();
public DbSet<AuthClientUsagePermission> AuthClientUsagePermissions => Set<AuthClientUsagePermission>();
public DbSet<AdminPermission> AdminPermissions => Set<AdminPermission>();
public DbSet<AdminRolePermission> AdminRolePermissions => Set<AdminRolePermission>();
public DbSet<FileAccessDownloadToken> FileAccessDownloadTokens => Set<FileAccessDownloadToken>();
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = null!;
@ -248,6 +250,34 @@ public class MemberCenterDbContext
entity.HasIndex(x => new { x.Usage, x.Scope }).IsUnique();
});
builder.Entity<AdminPermission>(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<AdminRolePermission>(entity =>
{
entity.ToTable("admin_role_permissions");
entity.HasKey(x => new { x.RoleId, x.PermissionId });
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
entity.HasOne<ApplicationRole>()
.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<FileAccessDownloadToken>(entity =>
{
entity.ToTable("file_access_download_tokens");

View File

@ -0,0 +1,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAdminPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "admin_permissions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
UpdatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
PermissionId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "admin_role_permissions");
migrationBuilder.DropTable(
name: "admin_permissions");
}
}
}

View File

@ -23,6 +23,69 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MemberCenter.Domain.Entities.AdminPermission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("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<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<Guid>("PermissionId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("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<Guid>("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");

View File

@ -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<ApplicationRole> _roleManager;
private Task<HashSet<string>>? _effectivePermissions;
public AdminPermissionService(MemberCenterDbContext dbContext, RoleManager<ApplicationRole> roleManager)
{
_dbContext = dbContext;
_roleManager = roleManager;
}
public async Task<bool> 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<HashSet<string>> LoadEffectivePermissionsAsync(
IReadOnlyCollection<string> 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)));
}
}
}

View File

@ -104,6 +104,8 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
await db.Database.MigrateAsync();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
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<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
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<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
await adminPermissionSeeder.EnsureDefaultsAsync();
var applicationManager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var applications = new List<object>();
@ -374,6 +380,9 @@ static IServiceProvider BuildServices(string connectionString)
.AddDefaultTokenProviders();
services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
services.AddScoped<AdminPermissionService>();
services.AddScoped<IAdminPermissionChecker>(provider => provider.GetRequiredService<AdminPermissionService>());
services.AddScoped<IAdminPermissionSeeder>(provider => provider.GetRequiredService<AdminPermissionService>());
return services.BuildServiceProvider();
}

View File

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ResetPassword(Guid id, string newPassword, string? search = null, string? role = null, string? status = null, string? verified = null)
{

View File

@ -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<IActionResult> Index()
{
var logs = await _auditLogService.ListAsync();

View File

@ -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<IActionResult> 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<IActionResult> Create(EmailBlacklistFormViewModel model)
{
if (!ModelState.IsValid)

View File

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

View File

@ -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<IActionResult> Index()
{
var lists = await _listService.ListAsync();
@ -27,6 +31,7 @@ public class NewsletterListsController : Controller
}
[HttpGet("create")]
[AdminPermission(AdminPermissions.NewsletterListsCreate)]
public async Task<IActionResult> Create()
{
var tenants = await _tenantService.ListAsync();
@ -37,6 +42,7 @@ public class NewsletterListsController : Controller
}
[HttpPost("create")]
[AdminPermission(AdminPermissions.NewsletterListsCreate)]
public async Task<IActionResult> Create(NewsletterListFormViewModel model)
{
if (!ModelState.IsValid)
@ -50,6 +56,7 @@ public class NewsletterListsController : Controller
}
[HttpGet("edit/{id:guid}")]
[AdminPermission(AdminPermissions.NewsletterListsEdit)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id)
{
await _listService.DeleteAsync(id);

View File

@ -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<IActionResult> Index()
{
var results = new List<object>();
@ -51,6 +55,7 @@ public class OAuthClientsController : Controller
}
[HttpGet("create")]
[AdminPermission(AdminPermissions.OAuthClientsCreate)]
public async Task<IActionResult> Create()
{
var tenants = await _tenantService.ListAsync();
@ -61,6 +66,7 @@ public class OAuthClientsController : Controller
}
[HttpPost("create")]
[AdminPermission(AdminPermissions.OAuthClientsCreate)]
public async Task<IActionResult> Create(OAuthClientFormViewModel model)
{
if (!IsValidUsage(model.Usage))
@ -131,6 +137,7 @@ public class OAuthClientsController : Controller
}
[HttpGet("edit/{id}")]
[AdminPermission(AdminPermissions.OAuthClientsEdit)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> RotateSecret(string id)
{
var app = await _applicationManager.FindByIdAsync(id);

View File

@ -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<IActionResult> Index()
{
var settings = await _settingsService.GetAsync();
@ -30,6 +34,7 @@ public class SecurityController : Controller
}
[HttpPost("")]
[AdminPermission(AdminPermissions.SecuritySave)]
public async Task<IActionResult> Save(SecuritySettingsDto model)
{
if (!ModelState.IsValid)
@ -52,6 +57,7 @@ public class SecurityController : Controller
}
[HttpPost("test-email")]
[AdminPermission(AdminPermissions.SecurityTestEmail)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> TestEmail(SecuritySettingsDto model)
{

View File

@ -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<IActionResult> Index()
{
var subscriptions = await _subscriptionService.ListAsync();
@ -25,6 +29,7 @@ public class SubscriptionsController : Controller
}
[HttpGet("export")]
[AdminPermission(AdminPermissions.SubscriptionsExport)]
public async Task<IActionResult> Export()
{
var subscriptions = await _subscriptionService.ListAsync();

View File

@ -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<IActionResult> 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<IActionResult> Create(TenantFormViewModel model)
{
if (!ModelState.IsValid)
@ -50,6 +56,7 @@ public class TenantsController : Controller
}
[HttpGet("edit/{id:guid}")]
[AdminPermission(AdminPermissions.TenantsEdit)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id)
{
await _tenantService.DeleteAsync(id);

View File

@ -1,7 +1,10 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.EmailBlacklistDto>
<h1>Email Blacklist</h1>
<p><a href="/admin/blacklist/create">Add</a></p>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.BlacklistCreate))
{
<p><a href="/admin/blacklist/create">Add</a></p>
}
<table>
<thead>
<tr><th>Email</th><th>Reason</th><th>By</th><th>At</th></tr>

View File

@ -1,7 +1,10 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.NewsletterListDto>
<h1>Newsletter Lists</h1>
<p><a href="/admin/newsletter-lists/create">Create</a></p>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsCreate))
{
<p><a href="/admin/newsletter-lists/create">Create</a></p>
}
<table>
<thead>
<tr><th>List ID</th><th>Name</th><th>Tenant</th><th>Status</th><th></th></tr>
@ -15,10 +18,16 @@
<td>@(string.IsNullOrWhiteSpace(list.TenantName) ? list.TenantId.ToString() : list.TenantName)</td>
<td>@list.Status</td>
<td>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsEdit))
{
<a href="/admin/newsletter-lists/edit/@list.Id">Edit</a>
}
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsDelete))
{
<form method="post" action="/admin/newsletter-lists/delete/@list.Id" style="display:inline">
<button type="submit">Delete</button>
</form>
}
</td>
</tr>
}

View File

@ -1,7 +1,10 @@
@model IReadOnlyList<object>
<h1>OAuth Clients</h1>
<p><a href="/admin/oauth-clients/create">Create</a></p>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsCreate))
{
<p><a href="/admin/oauth-clients/create">Create</a></p>
}
@if (TempData["CreatedClientId"] is string createdId)
{
<div>
@ -42,16 +45,23 @@
<td>@clientType</td>
<td>@usage</td>
<td>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsEdit))
{
<a href="/admin/oauth-clients/edit/@id">Edit</a>
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase))
{
}
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase)
&& await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsRotateSecret))
{
<form method="post" action="/admin/oauth-clients/rotate-secret/@id" style="display:inline">
<button type="submit">Rotate Secret</button>
</form>
}
}
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsDelete))
{
<form method="post" action="/admin/oauth-clients/delete/@id" style="display:inline">
<button type="submit">Delete</button>
</form>
}
</td>
</tr>
}

View File

@ -49,9 +49,14 @@
<label asp-for="SenderEmail">Sender email</label>
<input asp-for="SenderEmail" />
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecuritySave))
{
<button type="submit">Save</button>
}
</form>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecurityTestEmail))
{
<h2>Test Email</h2>
<form asp-action="TestEmail" method="post">
@Html.AntiForgeryToken()
@ -72,3 +77,4 @@
<input asp-for="TestToEmail" />
<button type="submit">Send Test Email</button>
</form>
}

View File

@ -1,3 +1,6 @@
@using MemberCenter.Application.Constants
@using MemberCenter.Application.Abstractions
@inject IAdminPermissionChecker AdminPermissionChecker
<!DOCTYPE html>
<html lang="en">
<head>
@ -36,15 +39,24 @@
<p class="admin-sidebar-copy">Lightweight structure for operations screens. Visual design can be replaced later.</p>
</div>
<nav class="admin-nav" aria-label="Admin navigation">
<a class="@NavClass("Home")" asp-area="Admin" asp-controller="Home" asp-action="Index">Overview</a>
<a class="@NavClass("Accounts")" asp-area="Admin" asp-controller="Accounts" asp-action="Index">Accounts</a>
<a class="@NavClass("Tenants")" asp-area="Admin" asp-controller="Tenants" asp-action="Index">Tenants</a>
<a class="@NavClass("NewsletterLists")" asp-area="Admin" asp-controller="NewsletterLists" asp-action="Index">Newsletter Lists</a>
<a class="@NavClass("Subscriptions")" asp-area="Admin" asp-controller="Subscriptions" asp-action="Index">Subscriptions</a>
<a class="@NavClass("OAuthClients")" asp-area="Admin" asp-controller="OAuthClients" asp-action="Index">OAuth Clients</a>
<a class="@NavClass("AuditLogs")" asp-area="Admin" asp-controller="AuditLogs" asp-action="Index">Audit Logs</a>
<a class="@NavClass("Security")" asp-area="Admin" asp-controller="Security" asp-action="Index">Security</a>
<a class="@NavClass("Blacklist")" asp-area="Admin" asp-controller="Blacklist" asp-action="Index">Blacklist</a>
@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))
{
<a class="@NavClass(item.Controller)" asp-area="Admin" asp-controller="@item.Controller" asp-action="Index">@item.Label</a>
}
}
</nav>
</aside>
<main class="admin-content">

View File

@ -1,7 +1,10 @@
@model IReadOnlyList<MemberCenter.Application.Models.Newsletter.SubscriptionDto>
<h1>Subscriptions</h1>
<p><a href="/admin/subscriptions/export">Export CSV</a></p>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SubscriptionsExport))
{
<p><a href="/admin/subscriptions/export">Export CSV</a></p>
}
<table>
<thead>
<tr><th>Email</th><th>List</th><th>Status</th><th>Created</th></tr>

View File

@ -1,7 +1,10 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto>
<h1>Tenants</h1>
<p><a href="/admin/tenants/create">Create</a></p>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsCreate))
{
<p><a href="/admin/tenants/create">Create</a></p>
}
<table>
<thead>
<tr><th>Name</th><th>Tenant Id</th><th>Webhook Client Id</th><th>Domains</th><th>Status</th><th></th></tr>
@ -16,10 +19,16 @@
<td>@string.Join(",", tenant.Domains)</td>
<td>@tenant.Status</td>
<td>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsEdit))
{
<a href="/admin/tenants/edit/@tenant.Id">Edit</a>
}
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsDelete))
{
<form method="post" action="/admin/tenants/delete/@tenant.Id" style="display:inline">
<button type="submit">Delete</button>
</form>
}
</td>
</tr>
}

View File

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

View File

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

View File

@ -135,6 +135,9 @@ builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>(
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
builder.Services.AddScoped<AdminPermissionService>();
builder.Services.AddScoped<IAdminPermissionChecker>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.AddScoped<IAdminPermissionSeeder>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
@ -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<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
}
static async Task EnsureAdminPermissionDefaultsAsync(IServiceProvider services)
{
await using var scope = services.CreateAsyncScope();
var seeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
await seeder.EnsureDefaultsAsync();
}

View File

@ -1,3 +1,6 @@
@using MemberCenter.Application.Constants
@using MemberCenter.Application.Abstractions
@inject IAdminPermissionChecker AdminPermissionChecker
<!DOCTYPE html>
<html lang="en">
<head>
@ -40,20 +43,29 @@
<a asp-area="" asp-controller="Profile" asp-action="Subscriptions">Subscriptions</a>
</div>
</div>
@if (User.IsInRole("admin") || User.IsInRole("superuser"))
@if (User.IsInRole(AdminPermissions.AdminRole) || User.IsInRole(AdminPermissions.SuperuserRole))
{
<div class="d-flex flex-column">
<span class="text-muted small text-uppercase">Admin</span>
<div class="d-flex gap-3 flex-wrap">
<a asp-area="Admin" asp-controller="Home" asp-action="Index">Overview</a>
<a asp-area="Admin" asp-controller="Accounts" asp-action="Index">Accounts</a>
<a asp-area="Admin" asp-controller="Tenants" asp-action="Index">Tenants</a>
<a asp-area="Admin" asp-controller="NewsletterLists" asp-action="Index">Newsletter Lists</a>
<a asp-area="Admin" asp-controller="Subscriptions" asp-action="Index">Subscriptions</a>
<a asp-area="Admin" asp-controller="OAuthClients" asp-action="Index">OAuth Clients</a>
<a asp-area="Admin" asp-controller="AuditLogs" asp-action="Index">Audit Logs</a>
<a asp-area="Admin" asp-controller="Security" asp-action="Index">Security</a>
<a asp-area="Admin" asp-controller="Blacklist" asp-action="Index">Blacklist</a>
@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))
{
<a asp-area="Admin" asp-controller="@item.Controller" asp-action="Index">@item.Label</a>
}
}
</div>
</div>
}