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
9 changes: 7 additions & 2 deletions src/Idmt.Plugin/Features/Manage/RegisterUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ public async Task<Result<RegisterUserResponse>> HandleAsync(
{
return Result.Failure<RegisterUserResponse>("Insufficient permissions to assign this role.", StatusCodes.Status403Forbidden);
}

// Get the tenant ID from the current user service (from tenant context)
var tenantId = currentUserService.TenantId
?? throw new InvalidOperationException("Tenant context is not available. Cannot register user without tenant context.");

// Create user entity with basic information, no password set
// User is active by default, but email is not confirmed until password is set
// When the user is unregistered, we set IsActive to false (soft delete)
Expand All @@ -140,7 +145,7 @@ public async Task<Result<RegisterUserResponse>> HandleAsync(
Email = request.Email,
EmailConfirmed = false, // Will be confirmed when password is set
IsActive = true,
TenantId = currentUserService.TenantId!,
TenantId = tenantId,
LastLoginAt = null,
};

Expand Down Expand Up @@ -204,7 +209,7 @@ public async Task<Result<RegisterUserResponse>> HandleAsync(
? linkGenerator.GeneratePasswordResetApiLink(user.Email, token)
: linkGenerator.GeneratePasswordResetFormLink(user.Email, token);

logger.LogInformation("User created: {Email}. Request by {RequestingUserId}. Tenant: {TenantId}.", user.Email, currentUserService.UserId, currentUserService.TenantId);
logger.LogInformation("User created: {Email}. Request by {RequestingUserId}. Tenant: {TenantId}.", user.Email, currentUserService.UserId, tenantId);

await emailSender.SendPasswordResetLinkAsync(user, user.Email, passwordSetupUrl);

Expand Down
15 changes: 11 additions & 4 deletions src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
using Finbuckle.MultiTenant.Abstractions;
using Idmt.Plugin.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;

namespace Idmt.Plugin.Middleware;

public class ValidateBearerTokenTenantMiddleware(IMultiTenantContextAccessor tenantContextAccessor) : IMiddleware
public class ValidateBearerTokenTenantMiddleware(
IMultiTenantContextAccessor tenantContextAccessor,
IOptions<IdmtOptions> idmtOptions) : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
Expand Down Expand Up @@ -39,7 +43,9 @@ private bool ValidateTokenTenant(
}

// Get the tenant claim type from configuration
string tenantClaimType = "__tenant__";
var tenantClaimType = idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(
IdmtMultiTenantStrategy.ClaimOption,
IdmtMultiTenantStrategy.DefaultClaimType);

// Extract tenant claim from token
var tokenTenantClaim = context.User.FindFirst(tenantClaimType)?.Value;
Expand All @@ -51,8 +57,9 @@ private bool ValidateTokenTenant(
return false;
}

// Validate that the token's tenant matches the current request's tenant
if (!tokenTenantClaim.Equals(currentTenant.Id, StringComparison.Ordinal))
// Validate that the token's tenant identifier matches the current request's tenant identifier
// The factory adds tenantInfo.Identifier to the claim, so we compare with Identifier, not Id
if (!tokenTenantClaim.Equals(currentTenant.Identifier, StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return false;
Expand Down
12 changes: 9 additions & 3 deletions src/Idmt.Plugin/Services/CurrentUserService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Security.Claims;
using Finbuckle.MultiTenant.Abstractions;
using Idmt.Plugin.Configuration;
using Microsoft.Extensions.Options;

namespace Idmt.Plugin.Services;

internal sealed class CurrentUserService(IOptions<IdmtOptions> idmtOptions) : ICurrentUserService
internal sealed class CurrentUserService(
IOptions<IdmtOptions> idmtOptions,
IMultiTenantContextAccessor multiTenantContextAccessor) : ICurrentUserService
{
public ClaimsPrincipal? User { get; private set; }

Expand All @@ -22,8 +25,11 @@ internal sealed class CurrentUserService(IOptions<IdmtOptions> idmtOptions) : IC
public string? UserName => User?.FindFirstValue(ClaimTypes.Name);

public string? TenantId =>
User?.FindFirstValue(idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(IdmtMultiTenantStrategy.ClaimOption,
IdmtMultiTenantStrategy.DefaultClaimType));
multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id;

public string? TenantIdentifier =>
User?.FindFirstValue(idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(IdmtMultiTenantStrategy.ClaimOption, IdmtMultiTenantStrategy.DefaultClaimType)) ??
multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Identifier;

public bool IsActive => User?.FindFirstValue("is_active") == "true";

Expand Down
1 change: 1 addition & 0 deletions src/Idmt.Plugin/Services/ICurrentUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public interface ICurrentUserService
string? Email { get; }
string? UserName { get; }
string? TenantId { get; }
string? TenantIdentifier { get; }

bool IsInRole(string role);

Expand Down
7 changes: 6 additions & 1 deletion src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Security.Claims;
using Finbuckle.MultiTenant.Abstractions;
using Idmt.Plugin.Configuration;
using Idmt.Plugin.Models;
using Microsoft.AspNetCore.Identity;
Expand All @@ -10,6 +11,7 @@ internal sealed class IdmtUserClaimsPrincipalFactory(
UserManager<IdmtUser> userManager,
RoleManager<IdmtRole> roleManager,
IOptions<IdentityOptions> optionsAccessor,
IMultiTenantStore<IdmtTenantInfo> tenantStore,
IOptions<IdmtOptions> idmtOptions)
: UserClaimsPrincipalFactory<IdmtUser, IdmtRole>(userManager, roleManager, optionsAccessor)
{
Expand All @@ -23,7 +25,10 @@ protected override async Task<ClaimsIdentity> GenerateClaimsAsync(IdmtUser user)
// Add tenant claim for multi-tenant strategies (header, claim, route)
// This ensures token validation includes tenant context
var claimKey = idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(IdmtMultiTenantStrategy.ClaimOption, IdmtMultiTenantStrategy.DefaultClaimType);
identity.AddClaim(new Claim(claimKey, user.TenantId));

// Try to get tenant info from store using user's TenantId
var tenantInfo = await tenantStore.GetAsync(user.TenantId) ?? throw new InvalidOperationException($"Tenant information not found for tenant ID: {user.TenantId}. User ID: {user.Id}");
identity.AddClaim(new Claim(claimKey, tenantInfo.Identifier));

return identity;
}
Expand Down
2 changes: 1 addition & 1 deletion src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public HttpClient CreateClientWithTenant(string? tenantId = null, bool allowAuto
}
if (_strategies.Contains(IdmtMultiTenantStrategy.Header))
{
client.DefaultRequestHeaders.TryAddWithoutValidation("__tenant__", tenantId);
client.DefaultRequestHeaders.TryAddWithoutValidation(IdmtMultiTenantStrategy.DefaultHeaderName, tenantId);
}
return client;
}
Expand Down
174 changes: 158 additions & 16 deletions src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,33 @@ namespace Idmt.UnitTests.Services;
/// </summary>
public class CurrentUserServiceTests
{
private readonly Mock<IMultiTenantContextAccessor> _tenantAccessorMock;
private readonly Mock<IOptions<IdmtOptions>> _optionsMock;
private readonly Mock<IMultiTenantContextAccessor> _tenantContextAccessorMock;
private readonly CurrentUserService _service;

public CurrentUserServiceTests()
{
_tenantAccessorMock = new Mock<IMultiTenantContextAccessor>();
_service = new CurrentUserService(_tenantAccessorMock.Object);
_optionsMock = new Mock<IOptions<IdmtOptions>>();
_optionsMock.Setup(x => x.Value).Returns(IdmtOptions.Default);
_tenantContextAccessorMock = new Mock<IMultiTenantContextAccessor>();
_service = new CurrentUserService(_optionsMock.Object, _tenantContextAccessorMock.Object);
}

[Fact]
public void UserId_ReturnsCurrentUserId_WhenUserExists()
{
var userId = Guid.NewGuid();
var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(new[]
{
new System.Security.Claims.ClaimsIdentity(
[
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, userId.ToString())
}));
]));

_service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

var result = _service.UserId;

Assert.NotNull(result);
Assert.Equal(userId, result);
}

Expand All @@ -56,17 +60,18 @@ public void UserId_ReturnsEmptyGuid_WhenUserDoesNotExist()

var result = _service.UserId;

Assert.NotNull(result);
Assert.Equal(Guid.Empty, result);
}

[Fact]
public void IsInRole_ReturnsTrue_WhenUserHasRole()
{
var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(new[]
{
new System.Security.Claims.ClaimsIdentity(
[
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "Admin")
}));
]));

_service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

Expand All @@ -79,10 +84,10 @@ public void IsInRole_ReturnsTrue_WhenUserHasRole()
public void IsInRole_ReturnsFalse_WhenUserDoesNotHaveRole()
{
var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(new[]
{
new System.Security.Claims.ClaimsIdentity(
[
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "User")
}));
]));

_service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

Expand All @@ -98,6 +103,143 @@ public void IsInRole_ReturnsFalse_WhenUserNotSet()

Assert.False(result);
}

[Fact]
public void TenantIdentifier_ReturnsTenantIdentifier_WhenClaimExists()
{
const string tenantId = "tenant-123";
var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
[
new System.Security.Claims.Claim(IdmtMultiTenantStrategy.DefaultClaimType, tenantId)
]));

_service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

var result = _service.TenantIdentifier;

Assert.Equal(tenantId, result);
}

[Fact]
public void TenantIdentifier_ReturnsNull_WhenClaimDoesNotExist()
{
var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity());

_service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

var result = _service.TenantIdentifier;

Assert.Null(result);
}

[Fact]
public void TenantIdentifier_ReturnsIdentifierFromTenantContext_WhenClaimDoesNotExist()
{
const string tenantIdentifier = "tenant-123";
var tenantInfo = new IdmtTenantInfo("tenant-id-123", tenantIdentifier, "Test Tenant");
var tenantContext = new MultiTenantContext<IdmtTenantInfo>(tenantInfo);

_tenantContextAccessorMock.Setup(x => x.MultiTenantContext).Returns(tenantContext);

var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity());

_service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

var result = _service.TenantIdentifier;

Assert.Equal(tenantIdentifier, result);
}

[Fact]
public void TenantIdentifier_ReturnsTenantIdentifier_WhenCustomClaimTypeIsConfigured()
{
const string customClaimType = "custom_tenant_claim";
const string tenantId = "tenant-456";

var customOptions = new IdmtOptions
{
MultiTenant = new MultiTenantOptions
{
StrategyOptions = new Dictionary<string, string>
{
{ IdmtMultiTenantStrategy.ClaimOption, customClaimType }
}
}
};

var customOptionsMock = new Mock<IOptions<IdmtOptions>>();
customOptionsMock.Setup(x => x.Value).Returns(customOptions);
var customTenantContextAccessorMock = new Mock<IMultiTenantContextAccessor>();
var customService = new CurrentUserService(customOptionsMock.Object, customTenantContextAccessorMock.Object);

var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
[
new System.Security.Claims.Claim(customClaimType, tenantId)
]));

customService.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

var result = customService.TenantIdentifier;

Assert.Equal(tenantId, result);
}

[Fact]
public void IsActive_ReturnsTrue_WhenClaimIsTrue()
{
var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
[
new System.Security.Claims.Claim("is_active", "true")
]));

_service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

var result = _service.IsActive;

Assert.True(result);
}

[Fact]
public void IsActive_ReturnsFalse_WhenClaimIsNotTrue()
{
var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
[
new System.Security.Claims.Claim("is_active", "false")
]));

_service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

var result = _service.IsActive;

Assert.False(result);
}

[Fact]
public void IsActive_ReturnsFalse_WhenClaimDoesNotExist()
{
var user = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity());

_service.SetCurrentUser(user, "127.0.0.1", "TestAgent/1.0");

var result = _service.IsActive;

Assert.False(result);
}

[Fact]
public void IsActive_ReturnsFalse_WhenUserNotSet()
{
var result = _service.IsActive;

Assert.False(result);
}
}

/// <summary>
Expand Down Expand Up @@ -228,7 +370,7 @@ public void CanManageUser_ReturnsFalse_WhenSysSupportManagesSysAdmin()
{
_currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.SysSupport)).Returns(true);

var result = _service.CanManageUser(new[] { IdmtDefaultRoleTypes.SysAdmin });
var result = _service.CanManageUser([IdmtDefaultRoleTypes.SysAdmin]);

Assert.False(result);
}
Expand All @@ -238,7 +380,7 @@ public void CanManageUser_ReturnsTrue_WhenSysSupportManagesTenantAdmin()
{
_currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.SysSupport)).Returns(true);

var result = _service.CanManageUser(new[] { IdmtDefaultRoleTypes.TenantAdmin });
var result = _service.CanManageUser([IdmtDefaultRoleTypes.TenantAdmin]);

Assert.True(result);
}
Expand All @@ -248,7 +390,7 @@ public void CanManageUser_ReturnsFalse_WhenTenantAdminManagesSysUser()
{
_currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.TenantAdmin)).Returns(true);

var result = _service.CanManageUser(new[] { IdmtDefaultRoleTypes.SysSupport });
var result = _service.CanManageUser([IdmtDefaultRoleTypes.SysSupport]);

Assert.False(result);
}
Expand All @@ -258,7 +400,7 @@ public void CanManageUser_ReturnsTrue_WhenTenantAdminManagesTenantUser()
{
_currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.TenantAdmin)).Returns(true);

var result = _service.CanManageUser(new[] { "CustomRole" });
var result = _service.CanManageUser(["CustomRole"]);

Assert.True(result);
}
Expand Down
Loading