Restore live platform compatibility contracts

This commit is contained in:
master
2026-03-10 01:37:24 +02:00
parent 6b7168ca3c
commit afb9711e61
15 changed files with 1790 additions and 27 deletions

View File

@@ -244,6 +244,99 @@ public class StellaOpsScopeAuthorizationHandlerTests
Assert.Equal("true", GetPropertyValue(record, "principal.authenticated"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Succeeds_WhenAnyScopeRequirementMatchesOneScope()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.53"));
var requirement = new StellaOpsScopeRequirement(
new[] { "quota.read", StellaOpsScopes.OrchQuota },
requireAllScopes: false);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-quota")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.OrchQuota })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Succeeds_WhenDefaultScopeConfigured_AndAnyScopeRequirementMatchesAlternateScope()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add("quota.read");
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.55"));
var requirement = new StellaOpsScopeRequirement(
new[] { "quota.read", StellaOpsScopes.OrchQuota },
requireAllScopes: false);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-quota")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.OrchQuota })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Fails_WhenAnyScopeRequirementMatchesNone()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.54"));
var requirement = new StellaOpsScopeRequirement(
new[] { "quota.read", StellaOpsScopes.OrchQuota },
requireAllScopes: false);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-quota")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
Assert.Equal("Required scopes not granted.", record.Reason);
Assert.Equal("orch:quota quota.read", GetPropertyValue(record, "resource.scopes.missing"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Fails_WhenIncidentAuthTimeMissing()

View File

@@ -33,6 +33,25 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
return builder;
}
/// <summary>
/// Requires that any one of the specified StellaOps scopes be present.
/// </summary>
public static AuthorizationPolicyBuilder RequireAnyStellaOpsScopes(
this AuthorizationPolicyBuilder builder,
params string[] scopes)
{
ArgumentNullException.ThrowIfNull(builder);
if (!builder.AuthenticationSchemes.Contains(StellaOpsAuthenticationDefaults.AuthenticationScheme))
{
builder.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
}
var requirement = new StellaOpsScopeRequirement(scopes, requireAllScopes: false);
builder.AddRequirements(requirement);
return builder;
}
/// <summary>
/// Registers a named policy that enforces the provided scopes.
/// </summary>
@@ -51,6 +70,24 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
});
}
/// <summary>
/// Registers a named policy that is satisfied when any listed scope is granted.
/// </summary>
public static void AddStellaOpsAnyScopePolicy(
this AuthorizationOptions options,
string policyName,
params string[] scopes)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(policyName);
options.AddPolicy(policyName, policy =>
{
policy.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes, requireAllScopes: false));
});
}
/// <summary>
/// Adds the scope handler to the DI container.
/// </summary>

View File

@@ -65,31 +65,10 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
? ExtractScopes(principal!)
: new HashSet<string>(StringComparer.Ordinal);
var anyScopeMatched = false;
var anyScopeMatched = combinedScopes.Any(principalScopes.Contains);
var missingScopes = new List<string>();
if (principalAuthenticated)
{
foreach (var scope in combinedScopes)
{
if (principalScopes.Contains(scope))
{
anyScopeMatched = true;
}
else
{
missingScopes.Add(scope);
}
}
}
else if (combinedScopes.Count > 0)
{
missingScopes.AddRange(combinedScopes);
}
var allScopesSatisfied = combinedScopes.Count == 0
? false
: missingScopes.Count == 0;
var allScopesSatisfied = principalAuthenticated &&
EvaluateScopeSatisfaction(resourceOptions.NormalizedScopes, requirement, principalScopes, missingScopes);
var tenantAllowed = false;
var tenantMismatch = false;
@@ -333,6 +312,42 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
packPlanHashClaim).ConfigureAwait(false);
}
private static bool EvaluateScopeSatisfaction(
IReadOnlyCollection<string> defaultScopes,
StellaOpsScopeRequirement requirement,
IReadOnlyCollection<string> principalScopes,
ICollection<string> missingScopes)
{
var resolvedDefaultScopes = defaultScopes as IReadOnlyList<string> ?? defaultScopes.ToArray();
var combinedScopes = CombineRequiredScopes(resolvedDefaultScopes, requirement.RequiredScopes);
if (requirement.RequireAllScopes)
{
foreach (var scope in combinedScopes)
{
if (!principalScopes.Contains(scope))
{
missingScopes.Add(scope);
}
}
return missingScopes.Count == 0;
}
var anyRequirementMatched = combinedScopes.Any(principalScopes.Contains);
if (anyRequirementMatched)
{
return true;
}
foreach (var scope in combinedScopes)
{
missingScopes.Add(scope);
}
return false;
}
private static string? DetermineFailureReason(
bool principalAuthenticated,
bool allScopesSatisfied,

View File

@@ -16,7 +16,11 @@ public sealed class StellaOpsScopeRequirement : IAuthorizationRequirement
/// Initialises a new instance of the <see cref="StellaOpsScopeRequirement"/> class.
/// </summary>
/// <param name="scopes">Scopes that satisfy the requirement.</param>
public StellaOpsScopeRequirement(IEnumerable<string> scopes)
/// <param name="requireAllScopes">
/// When <see langword="true"/>, every required scope must be present.
/// When <see langword="false"/>, any one required scope satisfies the requirement.
/// </param>
public StellaOpsScopeRequirement(IEnumerable<string> scopes, bool requireAllScopes = true)
{
ArgumentNullException.ThrowIfNull(scopes);
@@ -39,10 +43,16 @@ public sealed class StellaOpsScopeRequirement : IAuthorizationRequirement
}
RequiredScopes = normalized.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
RequireAllScopes = requireAllScopes;
}
/// <summary>
/// Gets the required scopes.
/// </summary>
public IReadOnlyCollection<string> RequiredScopes { get; }
/// <summary>
/// Gets a value indicating whether all listed scopes are required.
/// </summary>
public bool RequireAllScopes { get; }
}