Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,17 @@ Endpoints for managing user profiles and accounts.
| `PUT` | `/manage/users/{id}` | `RequireTenantManager` | Activate/Deactivate user |
| `DELETE` | `/manage/users/{id}` | `RequireTenantManager` | Delete a user |

### System & Tenant Access (`/sys`)
### System & Tenant Access (`/admin`)

System-level endpoints for managing tenant access.
* **Authorization**: `RequireSysUser` (SysAdmin or SysSupport roles).
* **Authorization**: `RequireAdminUser` (SysAdmin or SysSupport roles).

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/sys/info` | Get system version and environment info |
| `GET` | `/sys/users/{id}/tenants` | List tenants accessible by a user |
| `POST` | `/sys/users/{id}/tenants/{tenantId}` | Grant user access to a tenant |
| `DELETE` | `/sys/users/{id}/tenants/{tenantId}` | Revoke user access to a tenant |
| `GET` | `/admin/info` | Get system version and environment info |
| `GET` | `/admin/users/{id}/tenants` | List tenants accessible by a user |
| `POST` | `/admin/users/{id}/tenants/{tenantId}` | Grant user access to a tenant |
| `DELETE` | `/admin/users/{id}/tenants/{tenantId}` | Revoke user access to a tenant |

## Authorization Policies

Expand Down
7 changes: 4 additions & 3 deletions src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Finbuckle.MultiTenant.AspNetCore.Extensions;
using Idmt.Plugin.Configuration;
using Idmt.Plugin.Features;
using Idmt.Plugin.Features.Admin;
using Idmt.Plugin.Middleware;
using Idmt.Plugin.Models;
using Idmt.Plugin.Persistence;
Expand Down Expand Up @@ -76,7 +77,7 @@ public static IEndpointRouteBuilder MapIdmtEndpoints(this IEndpointRouteBuilder
{
endpoints.MapAuthEndpoints();
endpoints.MapAuthManageEndpoints();
endpoints.MapSysEndpoints();
endpoints.MapAdminEndpoints();
endpoints.MapHealthChecks("/healthz").RequireAuthorization(AuthOptions.RequireSysUserPolicy);
return endpoints;
}
Expand Down Expand Up @@ -162,8 +163,8 @@ public static async Task<IApplicationBuilder> SeedIdmtDataAsync(this IApplicatio
private static async Task SeedDefaultDataAsync(IServiceProvider services)
{
var options = services.GetRequiredService<IOptions<IdmtOptions>>();
var createTenantHandler = services.GetRequiredService<Features.Sys.CreateTenant.ICreateTenantHandler>();
await createTenantHandler.HandleAsync(new Features.Sys.CreateTenant.CreateTenantRequest(
var createTenantHandler = services.GetRequiredService<CreateTenant.ICreateTenantHandler>();
await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest(
MultiTenantOptions.DefaultTenantIdentifier,
MultiTenantOptions.DefaultTenantIdentifier,
options.Value.MultiTenant.DefaultTenantDisplayName));
Expand Down
14 changes: 8 additions & 6 deletions src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions;
using Finbuckle.MultiTenant.Extensions;
using Idmt.Plugin.Configuration;
using Idmt.Plugin.Features.Admin;
using Idmt.Plugin.Features.Auth;
using Idmt.Plugin.Features.Manage;
using Idmt.Plugin.Middleware;
Expand Down Expand Up @@ -401,12 +402,13 @@ private static void RegisterFeatures(IServiceCollection services)
services.AddScoped<GetUserInfo.IGetUserInfoHandler, GetUserInfo.GetUserInfoHandler>();
services.AddScoped<UpdateUserInfo.IUpdateUserInfoHandler, UpdateUserInfo.UpdateUserInfoHandler>();

// Sys
services.AddScoped<Features.Sys.CreateTenant.ICreateTenantHandler, Features.Sys.CreateTenant.CreateTenantHandler>();
services.AddScoped<Features.Sys.DeleteTenant.IDeleteTenantHandler, Features.Sys.DeleteTenant.DeleteTenantHandler>();
services.AddScoped<Features.Sys.GetUserTenants.IGetUserTenantsHandler, Features.Sys.GetUserTenants.GetUserTenantsHandler>();
services.AddScoped<Features.Sys.GrantTenantAccess.IGrantTenantAccessHandler, Features.Sys.GrantTenantAccess.GrantTenantAccessHandler>();
services.AddScoped<Features.Sys.RevokeTenantAccess.IRevokeTenantAccessHandler, Features.Sys.RevokeTenantAccess.RevokeTenantAccessHandler>();
// Admin
services.AddScoped<CreateTenant.ICreateTenantHandler, CreateTenant.CreateTenantHandler>();
services.AddScoped<DeleteTenant.IDeleteTenantHandler, DeleteTenant.DeleteTenantHandler>();
services.AddScoped<GetUserTenants.IGetUserTenantsHandler, GetUserTenants.GetUserTenantsHandler>();
services.AddScoped<GrantTenantAccess.IGrantTenantAccessHandler, GrantTenantAccess.GrantTenantAccessHandler>();
services.AddScoped<RevokeTenantAccess.IRevokeTenantAccessHandler, RevokeTenantAccess.RevokeTenantAccessHandler>();
services.AddScoped<GetAllTenants.IGetAllTenantsHandler, GetAllTenants.GetAllTenantsHandler>();

// Health
services.AddHealthChecks()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Finbuckle.MultiTenant.Abstractions;
using Idmt.Plugin.Configuration;
using Idmt.Plugin.Models;
using Idmt.Plugin.Validation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
Expand All @@ -11,7 +12,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Idmt.Plugin.Features.Sys;
namespace Idmt.Plugin.Features.Admin;

public static class CreateTenant
{
Expand Down Expand Up @@ -147,6 +148,10 @@ private async Task<bool> GuaranteeTenantRolesAsync(IdmtTenantInfo tenantInfo)
{
errors["Identifier"] = ["Identifier is required"];
}
else if (!Validators.IsValidTenantIdentifier(request.Identifier))
{
errors["Identifier"] = ["Identifier can only contain lowercase alphanumeric characters, dashes, and underscores"];
}
if (string.IsNullOrEmpty(request.Name))
{
errors["Name"] = ["Name is required"];
Expand Down Expand Up @@ -177,7 +182,7 @@ public static RouteHandlerBuilder MapCreateTenantEndpoint(this IEndpointRouteBui
}
return TypedResults.Ok(response.Value);
})
.RequireAuthorization(AuthOptions.RequireSysAdminPolicy)
.RequireAuthorization(AuthOptions.RequireSysUserPolicy)
.WithSummary("Create Tenant")
.WithDescription("Create a new tenant in the system or reactivate an existing inactive tenant");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;

namespace Idmt.Plugin.Features.Sys;
namespace Idmt.Plugin.Features.Admin;

public static class DeleteTenant
{
Expand All @@ -25,6 +25,10 @@ public async Task<Result> HandleAsync(string tenantIdentifier, CancellationToken
{
try
{
if (string.Compare(tenantIdentifier, MultiTenantOptions.DefaultTenantIdentifier, StringComparison.OrdinalIgnoreCase) == 0)
{
return Result.Failure("Cannot delete the default tenant", StatusCodes.Status403Forbidden);
}
var tenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier);
if (tenant is null)
{
Expand All @@ -48,7 +52,7 @@ public async Task<Result> HandleAsync(string tenantIdentifier, CancellationToken

public static RouteHandlerBuilder MapDeleteTenantEndpoint(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapDelete("/tenants/{tenantIdentifier}", async Task<Results<NoContent, NotFound, InternalServerError>> (
return endpoints.MapDelete("/tenants/{tenantIdentifier}", async Task<Results<NoContent, NotFound, InternalServerError, ForbidHttpResult>> (
[FromRoute] string tenantIdentifier,
[FromServices] IDeleteTenantHandler handler,
CancellationToken cancellationToken = default) =>
Expand All @@ -58,13 +62,14 @@ public static RouteHandlerBuilder MapDeleteTenantEndpoint(this IEndpointRouteBui
{
return result.StatusCode switch
{
StatusCodes.Status403Forbidden => TypedResults.Forbid(),
StatusCodes.Status404NotFound => TypedResults.NotFound(),
_ => TypedResults.InternalServerError(),
};
}
return TypedResults.NoContent();
})
.RequireAuthorization(AuthOptions.RequireSysUserPolicy)
.RequireAuthorization(AuthOptions.RequireSysAdminPolicy)
.WithSummary("Delete tenant")
.WithDescription("Soft deletes a tenant by its identifier");
}
Expand Down
62 changes: 62 additions & 0 deletions src/Idmt.Plugin/Features/Admin/GetAllTenants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Finbuckle.MultiTenant.Abstractions;
using Idmt.Plugin.Configuration;
using Idmt.Plugin.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;

namespace Idmt.Plugin.Features.Admin;

public static class GetAllTenants
{
public interface IGetAllTenantsHandler
{
Task<Result<TenantInfoResponse[]>> HandleAsync(CancellationToken cancellationToken = default);
}

internal sealed class GetAllTenantsHandler(
IMultiTenantStore<IdmtTenantInfo> tenantStore,
ILogger<GetAllTenantsHandler> logger) : IGetAllTenantsHandler
{
public async Task<Result<TenantInfoResponse[]>> HandleAsync(CancellationToken cancellationToken = default)
{
try
{
var tenants = await tenantStore.GetAllAsync();
var res = tenants.Where(t => t is not null).Select(t => new TenantInfoResponse(
t!.Id ?? string.Empty,
t.Identifier ?? string.Empty,
t.Name ?? string.Empty,
t.DisplayName ?? string.Empty,
t.Plan ?? string.Empty,
t.IsActive)).ToArray();

return Result.Success(res);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while retrieving all tenants");
return Result.Failure<TenantInfoResponse[]>($"An error occurred while retrieving tenants: {ex.Message}", StatusCodes.Status500InternalServerError);
}
}
}

public static RouteHandlerBuilder MapGetAllTenantsEndpoint(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapGet("/tenants", async Task<Results<Ok<TenantInfoResponse[]>, InternalServerError>> (
IGetAllTenantsHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(cancellationToken);
if (!result.IsSuccess)
{
return TypedResults.InternalServerError();
}
return TypedResults.Ok(result.Value!);
})
.RequireAuthorization(AuthOptions.RequireSysUserPolicy)
.WithSummary("Get tenants accessible by user");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;

namespace Idmt.Plugin.Features.Sys;
namespace Idmt.Plugin.Features.Admin;

public static class GetSystemInfo
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Idmt.Plugin.Features.Sys;
namespace Idmt.Plugin.Features.Admin;

public sealed record TenantInfoResponse(
string Id,
string Identifier,
string Name,
string DisplayName,
string Plan,
bool IsActive
);

public static class GetUserTenants
{
public sealed record TenantInfoResponse(
string Id,
string Identifier,
string Name,
string DisplayName,
string Plan
);

public interface IGetUserTenantsHandler
{
Task<Result<TenantInfoResponse[]>> HandleAsync(Guid userId, CancellationToken cancellationToken = default);
Expand Down Expand Up @@ -48,7 +49,8 @@ public async Task<Result<TenantInfoResponse[]>> HandleAsync(Guid userId, Cancell
t.Identifier ?? string.Empty,
t.Name ?? string.Empty,
t.DisplayName ?? string.Empty,
t.Plan ?? string.Empty)).ToArray();
t.Plan ?? string.Empty,
t.IsActive)).ToArray();

return Result.Success(res);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Idmt.Plugin.Features.Sys;
namespace Idmt.Plugin.Features.Admin;

public static class GrantTenantAccess
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Idmt.Plugin.Features.Sys;
namespace Idmt.Plugin.Features.Admin;

public static class RevokeTenantAccess
{
Expand Down
23 changes: 23 additions & 0 deletions src/Idmt.Plugin/Features/AdminEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Idmt.Plugin.Features.Admin;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace Idmt.Plugin.Features;

public static class AdminEndpoints
{
public static void MapAdminEndpoints(this IEndpointRouteBuilder endpoints)
{
var admin = endpoints.MapGroup("/admin")
.WithTags("Admin");

admin.MapCreateTenantEndpoint();
admin.MapDeleteTenantEndpoint();
admin.MapGetUserTenantsEndpoint();
admin.MapGrantTenantAccessEndpoint();
admin.MapRevokeTenantAccessEndpoint();
admin.MapGetSystemInfoEndpoint();
admin.MapGetAllTenantsEndpoint();
}
}
1 change: 0 additions & 1 deletion src/Idmt.Plugin/Features/Auth/ForgotPassword.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Text.Encodings.Web;
using Idmt.Plugin.Models;
using Idmt.Plugin.Services;
using Idmt.Plugin.Validation;
Expand Down
22 changes: 0 additions & 22 deletions src/Idmt.Plugin/Features/SysEndpoints.cs

This file was deleted.

13 changes: 13 additions & 0 deletions src/Idmt.Plugin/Validation/Validators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public static partial class Validators
[GeneratedRegex(@"[^a-zA-Z0-9]", RegexOptions.Compiled)]
public static partial Regex OneNonAlphaRegex();

[GeneratedRegex("^[a-z0-9-_]+$", RegexOptions.Compiled)]
public static partial Regex ValidIdentifier();

/// <summary>
/// Validates an email address.
/// </summary>
Expand Down Expand Up @@ -117,4 +120,14 @@ public static bool IsValidUsername(string? username)
{
return !string.IsNullOrWhiteSpace(username) && username.Length >= 3;
}

/// <summary>
/// Validates a tenant identifier (lowercase alphanumeric, dashes, underscores).
/// </summary>
/// <param name="identifier">The tenant identifier to validate.</param>
/// <returns>True if the identifier is valid, false otherwise.</returns>
public static bool IsValidTenantIdentifier(string? identifier)
{
return !string.IsNullOrWhiteSpace(identifier) && ValidIdentifier().IsMatch(identifier);
}
}
10 changes: 5 additions & 5 deletions src/samples/Idmt.BasicSample/wwwroot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,15 @@ The application supports multiple tenant resolution strategies:
| `PUT /manage/users/{id}` | Update user (activate/deactivate) | Yes | RequireTenantManager |
| `DELETE /manage/users/{id}` | Delete user | Yes | RequireTenantManager |

### System (`/sys`)
### System (`/admin`)

| Endpoint | Description | Auth Required | Policy |
|----------|-------------|---------------|--------|
| `GET /sys/info` | Get system information | Yes | Authenticated |
| `GET /admin/info` | Get system information | Yes | Authenticated |
| `GET /healthz` | Health check | Yes | Authenticated |
| `GET /sys/users/{id}/tenants` | List user's tenants | Yes | RequireSysUser |
| `POST /sys/users/{id}/tenants/{tenantId}` | Grant tenant access | Yes | RequireSysUser |
| `DELETE /sys/users/{id}/tenants/{tenantId}` | Revoke tenant access | Yes | RequireSysUser |
| `GET /admin/users/{id}/tenants` | List user's tenants | Yes | RequireAdminUser |
| `POST /admin/users/{id}/tenants/{tenantId}` | Grant tenant access | Yes | RequireAdminUser |
| `DELETE /admin/users/{id}/tenants/{tenantId}` | Revoke tenant access | Yes | RequireAdminUser |

## Typical Testing Workflow

Expand Down
Loading