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
330 changes: 205 additions & 125 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ private static void RegisterFeatures(IServiceCollection services)
services.AddScoped<ResendConfirmationEmail.IResendConfirmationEmailHandler, ResendConfirmationEmail.ResendConfirmationEmailHandler>();
services.AddScoped<ForgotPassword.IForgotPasswordHandler, ForgotPassword.ForgotPasswordHandler>();
services.AddScoped<ResetPassword.IResetPasswordHandler, ResetPassword.ResetPasswordHandler>();
services.AddScoped<DiscoverTenants.IDiscoverTenantsHandler, DiscoverTenants.DiscoverTenantsHandler>();

// Auth/Manage
services.AddScoped<RegisterUser.IRegisterUserHandler, RegisterUser.RegisterHandler>();
Expand Down
123 changes: 123 additions & 0 deletions src/Idmt.Plugin/Features/Auth/DiscoverTenants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using ErrorOr;
using FluentValidation;
using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
using Idmt.Plugin.Persistence;
using Idmt.Plugin.Services;
using Idmt.Plugin.Validation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Idmt.Plugin.Features.Auth;

public static class DiscoverTenants
{
public sealed record DiscoverTenantsRequest(string Email);

public sealed record TenantItem(string Identifier, string Name);

public sealed record DiscoverTenantsResponse(IReadOnlyList<TenantItem> Tenants);

public interface IDiscoverTenantsHandler
{
Task<ErrorOr<DiscoverTenantsResponse>> HandleAsync(
DiscoverTenantsRequest request,
CancellationToken cancellationToken = default);
}

internal sealed class DiscoverTenantsHandler(
IdmtDbContext dbContext,
TimeProvider timeProvider,
ILogger<DiscoverTenantsHandler> logger) : IDiscoverTenantsHandler
{
public async Task<ErrorOr<DiscoverTenantsResponse>> HandleAsync(
DiscoverTenantsRequest request,
CancellationToken cancellationToken = default)
{
try
{
var normalizedEmail = request.Email.ToUpperInvariant();
var now = timeProvider.GetUtcNow();

// Find all tenant IDs where the user has a direct account.
// IgnoreQueryFilters bypasses Finbuckle's automatic tenant filter
// so we can search across all tenants.
var directTenantIds = await dbContext.Users
.IgnoreQueryFilters()
.Where(u => u.NormalizedEmail == normalizedEmail && u.IsActive)
.Select(u => u.TenantId)
.Distinct()
.ToListAsync(cancellationToken);

// Find tenant IDs granted via TenantAccess (cross-tenant grants).
// First find user IDs matching the email, then look up their access grants.
var userIds = await dbContext.Users
.IgnoreQueryFilters()
.Where(u => u.NormalizedEmail == normalizedEmail && u.IsActive)
.Select(u => u.Id)
.ToListAsync(cancellationToken);

var accessTenantIds = await dbContext.TenantAccess
.Where(ta => userIds.Contains(ta.UserId)
&& ta.IsActive
&& (ta.ExpiresAt == null || ta.ExpiresAt > now))
.Select(ta => ta.TenantId)
.Distinct()
.ToListAsync(cancellationToken);

// Union all tenant IDs
var allTenantIds = directTenantIds.Union(accessTenantIds).ToList();

if (allTenantIds.Count == 0)
{
return new DiscoverTenantsResponse([]);
}

// Resolve tenant info, filtering only active tenants
var tenants = await dbContext.Set<IdmtTenantInfo>()
.Where(ti => allTenantIds.Contains(ti.Id) && ti.IsActive)
.OrderBy(ti => ti.Name)
.Select(ti => new TenantItem(ti.Identifier, ti.Name ?? ti.Identifier))
.ToListAsync(cancellationToken);

return new DiscoverTenantsResponse(tenants);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during tenant discovery for {Email}",
PiiMasker.MaskEmail(request.Email));
return IdmtErrors.General.Unexpected;
}
}
}

public static RouteHandlerBuilder MapDiscoverTenantsEndpoint(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapPost("/discover-tenants", async Task<Results<Ok<DiscoverTenantsResponse>, ValidationProblem, StatusCodeHttpResult>> (
[FromBody] DiscoverTenantsRequest request,
[FromServices] IDiscoverTenantsHandler handler,
[FromServices] IValidator<DiscoverTenantsRequest> validator,
HttpContext context) =>
{
if (ValidationHelper.Validate(request, validator) is { } validationErrors)
{
return TypedResults.ValidationProblem(validationErrors);
}

var result = await handler.HandleAsync(request, cancellationToken: context.RequestAborted);
if (result.IsError)
{
return TypedResults.StatusCode(StatusCodes.Status500InternalServerError);
}

return TypedResults.Ok(result.Value);
})
.WithSummary("Discover tenants by email")
.WithDescription("Resolve tenant(s) associated with an email address for pre-login discovery");
}
}
1 change: 1 addition & 0 deletions src/Idmt.Plugin/Features/AuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,6 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
auth.MapResendConfirmationEmailEndpoint();
auth.MapForgotPasswordEndpoint();
auth.MapResetPasswordEndpoint();
auth.MapDiscoverTenantsEndpoint();
}
}
13 changes: 13 additions & 0 deletions src/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentValidation;
using Idmt.Plugin.Features.Auth;

namespace Idmt.Plugin.Validation;

public class DiscoverTenantsRequestValidator : AbstractValidator<DiscoverTenants.DiscoverTenantsRequest>
{
public DiscoverTenantsRequestValidator()
{
RuleFor(x => x.Email).Must(Validators.IsValidEmail)
.WithMessage("Invalid email address.");
}
}
1 change: 1 addition & 0 deletions src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
<PackageReference Include="Microsoft.OpenApi" Version="2.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
107 changes: 91 additions & 16 deletions src/samples/Idmt.BasicSample/Program.cs
Original file line number Diff line number Diff line change
@@ -1,32 +1,107 @@
// ============================================================
// Idmt.BasicSample — showcasing the full IDMT plugin feature set
//
// What this sample demonstrates:
// - Cookie + Bearer dual-scheme authentication
// - Multi-tenant resolution via header and claim strategies
// - Role-based authorization (SysAdmin, TenantAdmin, custom roles)
// - Rate limiting on auth endpoints (disabled in Development)
// - Database initialization with EnsureCreated (SQLite)
// - OpenAPI document with Bearer security scheme
// - Seeding a default admin user on first run (SeedTestUser.cs)
//
// Default credentials (seeded on first run):
// Email: testadmin@example.com
// Password: TestAdmin123!
// ============================================================

using Finbuckle.MultiTenant.AspNetCore.Extensions;
using Idmt.Plugin.Configuration;
using Idmt.Plugin.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
// ----------------------------------------------------------
// OpenAPI — expose the Bearer security scheme in Swagger UI.
// IDMT does not configure OpenAPI itself; the host app owns it.
// ----------------------------------------------------------
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "opaque",
Description = "Bearer token obtained from POST /auth/login/token"
};
return Task.CompletedTask;
});
});

// ----------------------------------------------------------
// IDMT plugin registration
//
// AddIdmt parameters:
// configureDb — configure the EF Core provider (required)
// configureOptions — override any IdmtOptions value in code,
// applied on top of appsettings.json bindings
// customizeAuthentication / customizeAuthorization — extend
// the auth pipeline with additional schemes
// or policies without replacing the defaults
// ----------------------------------------------------------
builder.Services.AddSingleton<SeedDataAsync>(Idmt.BasicSample.SeedTestUser.SeedAsync);
builder.Services.AddIdmt(builder.Configuration, db => db.UseSqlite("Data Source=Idmt.BasicSample.db"));

builder.Services.AddIdmt(
builder.Configuration,
configureDb: db => db.UseSqlite("Data Source=Idmt.BasicSample.db"),
configureOptions: options =>
{
// Code-level overrides run after appsettings.json is bound,
// so they always win regardless of environment config files.

// Example: add application-specific roles that IDMT will seed
// alongside the built-in SysAdmin / TenantAdmin roles.
// options.Identity.ExtraRoles = ["Editor", "Viewer"];
});

// ----------------------------------------------------------
// HTTP pipeline
// ----------------------------------------------------------
var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
// /openapi/v1.json — excluded from multi-tenant resolution so it
// is always reachable regardless of the active tenant strategy.
app.MapOpenApi().ExcludeFromMultiTenantResolution();
}

// Enable static files and default files
// Serve the bundled HTML/CSS/JS frontend from wwwroot/.
app.UseDefaultFiles();
app.UseStaticFiles();

// Registers security headers, rate limiter (when enabled), multi-tenant
// middleware, authentication, authorization, and IDMT-specific middleware.
app.UseIdmt();

var options = app.Services.GetRequiredService<IOptions<IdmtOptions>>().Value;
// ----------------------------------------------------------
// Endpoint routing
//
// When the "route" strategy is active the tenant identifier is
// embedded in the URL path (e.g. /acme/api/v1/auth/login), so
// the endpoint group must expose the {__tenant__} route parameter.
// All other strategies (header, claim, basepath) use a plain group.
// ----------------------------------------------------------
var idmtOptions = app.Services.GetRequiredService<IOptions<IdmtOptions>>().Value;

if (options.MultiTenant.Strategies.Contains(IdmtMultiTenantStrategy.Route))
if (idmtOptions.MultiTenant.Strategies.Contains(IdmtMultiTenantStrategy.Route))
{
app.MapGroup("/{__tenant__}").MapIdmtEndpoints();
}
Expand All @@ -35,18 +110,18 @@
app.MapGroup("").MapIdmtEndpoints();
}

// ----------------------------------------------------------
// Database initialization and data seeding
//
// EnsureIdmtDatabaseAsync honours the DatabaseInitialization mode
// from configuration (EnsureCreated / Migrate / None).
// SeedIdmtDataAsync creates the default system tenant and then
// runs the optional custom seed delegate registered above.
// ----------------------------------------------------------
await app.EnsureIdmtDatabaseAsync();

var seedAction = app.Services.GetService<SeedDataAsync>();
await app.SeedIdmtDataAsync(seedAction);

// Seed test user in development
// if (app.Environment.IsDevelopment())
// {
// using var scope = app.Services.CreateScope();
// await Idmt.BasicSample.SeedTestUser.SeedAsync(scope.ServiceProvider);
// }
await app.SeedIdmtDataAsync(app.Services.GetService<SeedDataAsync>());

app.Run();

// Required by integration tests (WebApplicationFactory<Program>).
public partial class Program;
21 changes: 13 additions & 8 deletions src/samples/Idmt.BasicSample/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Idmt": {
"RateLimiting": {
// Disable rate limiting locally so repeated test calls are never rejected.
"Enabled": false
},
"MultiTenant": {
"DefaultTenantId": "system-tenant",
"Strategies": ["header", "claim"],
Expand All @@ -14,5 +12,12 @@
"claim": "tenant-identifier"
}
}
}
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
// Verbose IDMT logging in development — shows tenant resolution, token handling, etc.
"Idmt": "Debug"
}
},
}
Loading