Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/EntraAppRoleResolverTests.cs
T
Oliver Kieselbach 879bd0c59b feat(backend): optional per-tenant Entra app-role RBAC (Admin/Operator)
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>
2026-06-17 18:54:39 +02:00

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