consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -66,12 +66,12 @@ public static class StellaOpsClaimTypes
|
||||
public const string IdentityProvider = "stellaops:idp";
|
||||
|
||||
/// <summary>
|
||||
/// Operator reason supplied when issuing orchestrator control tokens.
|
||||
/// Operator reason supplied when issuing jobengine control tokens.
|
||||
/// </summary>
|
||||
public const string OperatorReason = "stellaops:operator_reason";
|
||||
|
||||
/// <summary>
|
||||
/// Operator ticket supplied when issuing orchestrator control tokens.
|
||||
/// Operator ticket supplied when issuing jobengine control tokens.
|
||||
/// </summary>
|
||||
public const string OperatorTicket = "stellaops:operator_ticket";
|
||||
|
||||
@@ -86,12 +86,12 @@ public static class StellaOpsClaimTypes
|
||||
public const string QuotaTicket = "stellaops:quota_ticket";
|
||||
|
||||
/// <summary>
|
||||
/// Backfill activation reason supplied when issuing orchestrator backfill tokens.
|
||||
/// Backfill activation reason supplied when issuing jobengine backfill tokens.
|
||||
/// </summary>
|
||||
public const string BackfillReason = "stellaops:backfill_reason";
|
||||
|
||||
/// <summary>
|
||||
/// Backfill ticket/incident reference supplied when issuing orchestrator backfill tokens.
|
||||
/// Backfill ticket/incident reference supplied when issuing jobengine backfill tokens.
|
||||
/// </summary>
|
||||
public const string BackfillTicket = "stellaops:backfill_ticket";
|
||||
|
||||
|
||||
@@ -373,7 +373,7 @@ public static class StellaOpsScopes
|
||||
public const string OrchQuota = "orch:quota";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to initiate orchestrator-controlled backfill runs.
|
||||
/// Scope granting permission to initiate jobengine-controlled backfill runs.
|
||||
/// </summary>
|
||||
public const string OrchBackfill = "orch:backfill";
|
||||
|
||||
@@ -597,6 +597,13 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string AnalyticsRead = "analytics.read";
|
||||
|
||||
// UI preferences scopes
|
||||
public const string UiPreferencesRead = "ui.preferences.read";
|
||||
public const string UiPreferencesWrite = "ui.preferences.write";
|
||||
|
||||
// Platform ops health scope
|
||||
public const string OpsHealth = "ops.health";
|
||||
|
||||
// Platform context scopes
|
||||
public const string PlatformContextRead = "platform.context.read";
|
||||
public const string PlatformContextWrite = "platform.context.write";
|
||||
|
||||
@@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
@@ -90,8 +92,30 @@ public static class ServiceCollectionExtensions
|
||||
// Accept both "Bearer" and "DPoP" authorization schemes.
|
||||
// The StellaOps UI sends DPoP-bound access tokens with "Authorization: DPoP <token>".
|
||||
jwt.Events ??= new JwtBearerEvents();
|
||||
var bridgeLogger = provider.GetService<ILoggerFactory>()?.CreateLogger("StellaOps.Auth.EnvelopeBridge");
|
||||
jwt.Events.OnMessageReceived = context =>
|
||||
{
|
||||
// Bridge: accept Router-signed identity envelope as a valid StellaOpsBearer identity.
|
||||
// Valkey-transported requests carry no JWT; the gateway already validated the token
|
||||
// and signed the claims into an HMAC-SHA256 envelope that the Router SDK verified
|
||||
// and populated onto httpContext.User before the ASP.NET Core pipeline runs.
|
||||
var identity = context.HttpContext.User?.Identity;
|
||||
if (identity is ClaimsIdentity { IsAuthenticated: true, AuthenticationType: "StellaRouterEnvelope" } envelopeId)
|
||||
{
|
||||
bridgeLogger?.LogInformation(
|
||||
"Envelope bridge: accepting identity {Subject} with {ScopeCount} scopes as StellaOpsBearer",
|
||||
envelopeId.FindFirst("sub")?.Value ?? "(unknown)",
|
||||
envelopeId.FindAll("scope").Count());
|
||||
context.Principal = context.HttpContext.User;
|
||||
context.Success();
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
bridgeLogger?.LogDebug(
|
||||
"Envelope bridge: no envelope identity (AuthType={AuthType}, IsAuth={IsAuth})",
|
||||
identity?.AuthenticationType ?? "(null)",
|
||||
identity?.IsAuthenticated ?? false);
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Token))
|
||||
{
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
@@ -12,6 +13,9 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Requires the specified scopes using the StellaOps scope requirement.
|
||||
/// Explicitly binds the policy to the StellaOpsBearer authentication scheme
|
||||
/// so that token validation uses the correct JWT handler regardless of the
|
||||
/// application's default authentication scheme.
|
||||
/// </summary>
|
||||
public static AuthorizationPolicyBuilder RequireStellaOpsScopes(
|
||||
this AuthorizationPolicyBuilder builder,
|
||||
@@ -19,6 +23,11 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
if (!builder.AuthenticationSchemes.Contains(StellaOpsAuthenticationDefaults.AuthenticationScheme))
|
||||
{
|
||||
builder.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
var requirement = new StellaOpsScopeRequirement(scopes);
|
||||
builder.AddRequirements(requirement);
|
||||
return builder;
|
||||
@@ -37,6 +46,7 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
|
||||
|
||||
options.AddPolicy(policyName, policy =>
|
||||
{
|
||||
policy.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -11,6 +16,8 @@ namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
|
||||
internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
{
|
||||
private const string DefaultTenantId = "default";
|
||||
|
||||
private readonly string pluginName;
|
||||
private readonly IServiceScopeFactory scopeFactory;
|
||||
private readonly ILogger<StandardPluginBootstrapper> logger;
|
||||
@@ -46,6 +53,122 @@ internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
{
|
||||
logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to ensure bootstrap user.", pluginName);
|
||||
}
|
||||
|
||||
var tenantId = options.TenantId ?? DefaultTenantId;
|
||||
var bootstrapRoles = options.BootstrapUser.Roles ?? new[] { "admin" };
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureAdminRoleAsync(scope.ServiceProvider, tenantId, options.BootstrapUser.Username!, bootstrapRoles, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to seed admin role with scopes.", pluginName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureAdminRoleAsync(
|
||||
IServiceProvider services,
|
||||
string tenantId,
|
||||
string bootstrapUsername,
|
||||
string[] roleNames,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var roleRepository = services.GetRequiredService<IRoleRepository>();
|
||||
var permissionRepository = services.GetRequiredService<IPermissionRepository>();
|
||||
var userRepository = services.GetRequiredService<IUserRepository>();
|
||||
|
||||
var allScopes = StellaOpsScopes.All;
|
||||
|
||||
foreach (var roleName in roleNames)
|
||||
{
|
||||
var existingRole = await roleRepository.GetByNameAsync(tenantId, roleName, cancellationToken).ConfigureAwait(false);
|
||||
Guid roleId;
|
||||
|
||||
if (existingRole is null)
|
||||
{
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' creating system role '{RoleName}' with {ScopeCount} scopes.",
|
||||
pluginName, roleName, allScopes.Count);
|
||||
|
||||
roleId = await roleRepository.CreateAsync(tenantId, new RoleEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = roleName,
|
||||
DisplayName = roleName == "admin" ? "Administrator" : roleName,
|
||||
Description = roleName == "admin" ? "Full platform access. Auto-seeded by bootstrap." : $"System role '{roleName}'. Auto-seeded by bootstrap.",
|
||||
IsSystem = true,
|
||||
Metadata = "{}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
roleId = existingRole.Id;
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' role '{RoleName}' already exists (id={RoleId}). Ensuring scope assignments.",
|
||||
pluginName, roleName, roleId);
|
||||
}
|
||||
|
||||
// Ensure permissions exist for all scopes and are assigned to the role
|
||||
var existingPermissions = await permissionRepository.GetRolePermissionsAsync(tenantId, roleId, cancellationToken).ConfigureAwait(false);
|
||||
var existingPermissionNames = existingPermissions.Select(p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var scope in allScopes)
|
||||
{
|
||||
if (existingPermissionNames.Contains(scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse scope into resource:action (e.g. "release:read" -> resource="release", action="read")
|
||||
var separatorIndex = scope.IndexOfAny(new[] { ':', '.' });
|
||||
var resource = separatorIndex > 0 ? scope[..separatorIndex] : scope;
|
||||
var action = separatorIndex > 0 ? scope[(separatorIndex + 1)..] : "access";
|
||||
|
||||
// Check if the permission already exists globally
|
||||
var existingPermission = await permissionRepository.GetByNameAsync(tenantId, scope, cancellationToken).ConfigureAwait(false);
|
||||
Guid permissionId;
|
||||
|
||||
if (existingPermission is null)
|
||||
{
|
||||
permissionId = await permissionRepository.CreateAsync(tenantId, new PermissionEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = scope,
|
||||
Resource = resource,
|
||||
Action = action,
|
||||
Description = $"Auto-seeded permission for scope '{scope}'.",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
permissionId = existingPermission.Id;
|
||||
}
|
||||
|
||||
await permissionRepository.AssignToRoleAsync(tenantId, roleId, permissionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' role '{RoleName}' now has {ScopeCount} scope permissions.",
|
||||
pluginName, roleName, allScopes.Count);
|
||||
|
||||
// Assign the role to the bootstrap user
|
||||
var normalizedUsername = bootstrapUsername.Trim().ToLowerInvariant();
|
||||
var bootstrapUser = await userRepository.GetByUsernameAsync(tenantId, normalizedUsername, cancellationToken).ConfigureAwait(false);
|
||||
if (bootstrapUser is not null)
|
||||
{
|
||||
await roleRepository.AssignToUserAsync(tenantId, bootstrapUser.Id, roleId, "bootstrap", expiresAt: null, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' assigned role '{RoleName}' to bootstrap user '{Username}'.",
|
||||
pluginName, roleName, normalizedUsername);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("Standard Authority plugin '{PluginName}' could not find bootstrap user '{Username}' to assign role '{RoleName}'.",
|
||||
pluginName, normalizedUsername, roleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
@@ -52,6 +52,8 @@ internal sealed class BootstrapUserOptions
|
||||
|
||||
public bool RequirePasswordReset { get; set; } = true;
|
||||
|
||||
public string[]? Roles { get; set; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password);
|
||||
|
||||
public void Validate(string pluginName)
|
||||
|
||||
@@ -344,7 +344,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
displayName: bootstrap.Username,
|
||||
email: null,
|
||||
requirePasswordReset: bootstrap.RequirePasswordReset,
|
||||
roles: Array.Empty<string>(),
|
||||
roles: bootstrap.Roles ?? new[] { "admin" },
|
||||
attributes: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var result = await UpsertUserAsync(registration, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -871,7 +871,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = invalidScope;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Orchestrator scopes require a tenant assignment.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: orchestrator scopes require tenant assignment.",
|
||||
"Client credentials validation failed for {ClientId}: jobengine scopes require tenant assignment.",
|
||||
document.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ internal static class OpenIddictGatewayBridgeEndpointExtensions
|
||||
.WithSummary("OAuth2 revocation endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/revoke` requests to Authority `/revoke`.");
|
||||
|
||||
endpoints.MapGet("/well-known/openid-configuration", (
|
||||
endpoints.MapGet("/.well-known/openid-configuration", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
|
||||
@@ -319,7 +319,10 @@ builder.Services.AddOptions<StellaOpsResourceServerOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.Authority = issuerUri.ToString();
|
||||
options.RequireHttpsMetadata = !issuerUri.IsLoopback;
|
||||
// Use loopback metadata endpoint so the Authority can fetch its own JWKS
|
||||
// without requiring external DNS resolution or TLS certificate trust.
|
||||
options.MetadataAddress = "http://127.0.0.1/.well-known/openid-configuration";
|
||||
options.RequireHttpsMetadata = false;
|
||||
})
|
||||
.PostConfigure(static options => options.Validate());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user