mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
879bd0c59b
Grant tenant member roles via Entra ID app-role assignments on the Enterprise App (the token's "roles" claim), in addition to the TenantAdmins table. Per-tenant opt-in via the new GA-only EntraAppRolesEnabled flag (backend-only, no ConfigVersion bump). Resolution is table-first and tri-state (NotPresent/Disabled/Enabled): an enabled table row wins, a disabled row is an explicit deny that suppresses the claim entirely (a disabled member can never be re-authorized through their app-role assignment), and only a missing row falls back to the claim. Only Admin and Operator are claim-mappable (Admin outranks Operator); Viewer and the platform GlobalAdmin role are never claim-derived. All effective-role consumers share one pure decision authority (EntraAppRoleResolver.Resolve): PolicyEnforcementMiddleware, auth/me, and feedback/status — the latter previously used a table-only check, so Entra-only members did not see the feedback bubble. GA toggle wired through the BootstrapToken pattern: TableConfigRepository persistence, UpdateTenantConfigurationFunction GA-only protection, and a checkbox in the admin tenant editor. Tests: EntraAppRoleResolverTests (incl. disabled-row deny) + an auto-promote regression guard; 2308/2308 backend green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
4.2 KiB
C#
124 lines
4.2 KiB
C#
using AutopilotMonitor.Functions.Security;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="EntraAppRoleResolver"/> — the pure logic that maps Entra app-role claims
|
|
/// to a tenant member role and reconciles them with the TenantAdmins table (table wins).
|
|
/// </summary>
|
|
public class EntraAppRoleResolverTests
|
|
{
|
|
// -------------------------------------------------------------------------
|
|
// MapClaimRole
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void MapClaimRole_NullOrEmpty_ReturnsNull()
|
|
{
|
|
Assert.Null(EntraAppRoleResolver.MapClaimRole(null));
|
|
Assert.Null(EntraAppRoleResolver.MapClaimRole(new string[0]));
|
|
Assert.Null(EntraAppRoleResolver.MapClaimRole(new[] { " ", "" }));
|
|
}
|
|
|
|
[Fact]
|
|
public void MapClaimRole_UnknownRole_ReturnsNull()
|
|
{
|
|
Assert.Null(EntraAppRoleResolver.MapClaimRole(new[] { "Viewer", "SomethingElse" }));
|
|
}
|
|
|
|
[Fact]
|
|
public void MapClaimRole_Admin_GrantsBootstrapPermission()
|
|
{
|
|
var role = EntraAppRoleResolver.MapClaimRole(new[] { Constants.TenantRoles.Admin });
|
|
|
|
Assert.NotNull(role);
|
|
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
|
|
Assert.True(role.CanManageBootstrapTokens);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapClaimRole_Operator_DoesNotGrantBootstrapPermission()
|
|
{
|
|
var role = EntraAppRoleResolver.MapClaimRole(new[] { Constants.TenantRoles.Operator });
|
|
|
|
Assert.NotNull(role);
|
|
Assert.Equal(Constants.TenantRoles.Operator, role!.Role);
|
|
Assert.False(role.CanManageBootstrapTokens);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapClaimRole_AdminOutranksOperator_WhenBothPresent()
|
|
{
|
|
var role = EntraAppRoleResolver.MapClaimRole(
|
|
new[] { Constants.TenantRoles.Operator, Constants.TenantRoles.Admin });
|
|
|
|
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapClaimRole_IsCaseInsensitive()
|
|
{
|
|
var role = EntraAppRoleResolver.MapClaimRole(new[] { "admin" });
|
|
|
|
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Resolve (table-first, tri-state)
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Resolve_EnabledTableRole_AlwaysWins_EvenOverHigherClaim()
|
|
{
|
|
var tableRole = new MemberRoleInfo
|
|
{
|
|
Role = Constants.TenantRoles.Operator,
|
|
CanManageBootstrapTokens = true // manual override the admin set
|
|
};
|
|
|
|
var result = EntraAppRoleResolver.Resolve(
|
|
TableMemberState.Enabled, tableRole, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: true);
|
|
|
|
// Table Operator wins over claim Admin; the manual bootstrap override is preserved.
|
|
Assert.Equal(Constants.TenantRoles.Operator, result!.Role);
|
|
Assert.True(result.CanManageBootstrapTokens);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_DisabledTableRow_IsExplicitDeny_IgnoresClaim()
|
|
{
|
|
// The security-critical case: a disabled member must NOT be re-authorized via the claim.
|
|
var result = EntraAppRoleResolver.Resolve(
|
|
TableMemberState.Disabled, null, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: true);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_NoTableRow_FallsBackToClaim_WhenEnabled()
|
|
{
|
|
var result = EntraAppRoleResolver.Resolve(
|
|
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Operator }, appRolesEnabled: true);
|
|
|
|
Assert.Equal(Constants.TenantRoles.Operator, result!.Role);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_NoTableRow_IgnoresClaim_WhenTenantFlagDisabled()
|
|
{
|
|
var result = EntraAppRoleResolver.Resolve(
|
|
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: false);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_NoTableRow_NoClaim_ReturnsNull()
|
|
{
|
|
Assert.Null(EntraAppRoleResolver.Resolve(
|
|
TableMemberState.NotPresent, null, new string[0], appRolesEnabled: true));
|
|
}
|
|
}
|