search and ai stabilization work, localization stablized.

This commit is contained in:
master
2026-02-24 23:29:36 +02:00
parent 4f947a8b61
commit b07d27772e
766 changed files with 55299 additions and 3221 deletions

View File

@@ -107,6 +107,17 @@ public static class StellaOpsLocalHostnameExtensions
var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? "";
builder.WebHost.ConfigureKestrel((context, kestrel) =>
{
// Load the configured default certificate (if any) so programmatic
// UseHttps() calls can present a valid cert instead of relying on
// the ASP.NET dev-cert (which doesn't exist in containers).
X509Certificate2? defaultCert = null;
var certPath = context.Configuration["Kestrel:Certificates:Default:Path"];
var certPass = context.Configuration["Kestrel:Certificates:Default:Password"];
if (!string.IsNullOrEmpty(certPath) && System.IO.File.Exists(certPath))
{
defaultCert = X509CertificateLoader.LoadPkcs12FromFile(certPath, certPass);
}
// Re-add dev-port bindings from launchSettings.json / ASPNETCORE_URLS
foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
@@ -119,7 +130,13 @@ public static class StellaOpsLocalHostnameExtensions
if (isHttps)
{
kestrel.Listen(addr, uri.Port, lo => lo.UseHttps());
kestrel.Listen(addr, uri.Port, lo =>
{
if (defaultCert is not null)
lo.UseHttps(defaultCert);
else
lo.UseHttps();
});
}
else
{
@@ -133,7 +150,10 @@ public static class StellaOpsLocalHostnameExtensions
{
kestrel.Listen(bindIp, HttpsPort, listenOptions =>
{
listenOptions.UseHttps();
if (defaultCert is not null)
listenOptions.UseHttps(defaultCert);
else
listenOptions.UseHttps();
});
}

View File

@@ -13,20 +13,98 @@ namespace StellaOps.Authority;
internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProviderRegistry
{
private readonly IServiceProvider serviceProvider;
private readonly IReadOnlyDictionary<string, AuthorityIdentityProviderMetadata> providersByName;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> providers;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> passwordProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> mfaProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> clientProvisioningProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> bootstrapProviders;
private readonly ILogger<AuthorityIdentityProviderRegistry> logger;
private volatile IReadOnlyDictionary<string, AuthorityIdentityProviderMetadata> providersByName;
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> providers;
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> passwordProviders;
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> mfaProviders;
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> clientProvisioningProviders;
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> bootstrapProviders;
private volatile AuthorityIdentityProviderCapabilities aggregateCapabilities;
public AuthorityIdentityProviderRegistry(
IServiceProvider serviceProvider,
ILogger<AuthorityIdentityProviderRegistry> logger)
{
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Initialise all volatile fields to empty defaults so Rebuild never
// reads uninitialised state from another thread.
providersByName = new Dictionary<string, AuthorityIdentityProviderMetadata>(StringComparer.OrdinalIgnoreCase);
providers = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
passwordProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
mfaProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
clientProvisioningProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
bootstrapProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
aggregateCapabilities = new AuthorityIdentityProviderCapabilities(false, false, false, false);
Rebuild();
}
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders => passwordProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> BootstrapProviders => bootstrapProviders;
public AuthorityIdentityProviderCapabilities AggregateCapabilities => aggregateCapabilities;
public bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata)
{
if (string.IsNullOrWhiteSpace(name))
{
metadata = null;
return false;
}
return providersByName.TryGetValue(name, out metadata);
}
public async ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken)
{
if (!providersByName.TryGetValue(name, out var metadata))
{
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
}
cancellationToken.ThrowIfCancellationRequested();
var scope = serviceProvider.CreateAsyncScope();
try
{
var provider = scope.ServiceProvider
.GetServices<IIdentityProviderPlugin>()
.FirstOrDefault(p => string.Equals(p.Name, metadata.Name, StringComparison.OrdinalIgnoreCase));
if (provider is null)
{
await scope.DisposeAsync().ConfigureAwait(false);
throw new InvalidOperationException($"Identity provider plugin '{metadata.Name}' could not be resolved.");
}
cancellationToken.ThrowIfCancellationRequested();
return new AuthorityIdentityProviderHandle(scope, metadata, provider);
}
catch
{
await scope.DisposeAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Re-scans <see cref="IIdentityProviderPlugin"/> instances from the DI
/// container and rebuilds the metadata and capability indexes. This is
/// called during startup and when the plugin configuration is reloaded at
/// runtime.
/// </summary>
internal void Rebuild()
{
using var scope = serviceProvider.CreateScope();
var providerInstances = scope.ServiceProvider.GetServices<IIdentityProviderPlugin>();
@@ -87,72 +165,17 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
}
}
// Volatile writes ensure visibility to concurrent readers.
providersByName = dictionary;
providers = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(uniqueProviders);
passwordProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(password);
mfaProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(mfa);
clientProvisioningProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(clientProvisioning);
bootstrapProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(bootstrap);
AggregateCapabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: passwordProviders.Count > 0,
SupportsMfa: mfaProviders.Count > 0,
SupportsClientProvisioning: clientProvisioningProviders.Count > 0,
SupportsBootstrap: bootstrapProviders.Count > 0);
}
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders => passwordProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> BootstrapProviders => bootstrapProviders;
public AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
public bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata)
{
if (string.IsNullOrWhiteSpace(name))
{
metadata = null;
return false;
}
return providersByName.TryGetValue(name, out metadata);
}
public async ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken)
{
if (!providersByName.TryGetValue(name, out var metadata))
{
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
}
cancellationToken.ThrowIfCancellationRequested();
var scope = serviceProvider.CreateAsyncScope();
try
{
var provider = scope.ServiceProvider
.GetServices<IIdentityProviderPlugin>()
.FirstOrDefault(p => string.Equals(p.Name, metadata.Name, StringComparison.OrdinalIgnoreCase));
if (provider is null)
{
await scope.DisposeAsync().ConfigureAwait(false);
throw new InvalidOperationException($"Identity provider plugin '{metadata.Name}' could not be resolved.");
}
cancellationToken.ThrowIfCancellationRequested();
return new AuthorityIdentityProviderHandle(scope, metadata, provider);
}
catch
{
await scope.DisposeAsync().ConfigureAwait(false);
throw;
}
aggregateCapabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: password.Count > 0,
SupportsMfa: mfa.Count > 0,
SupportsClientProvisioning: clientProvisioning.Count > 0,
SupportsBootstrap: bootstrap.Count > 0);
}
}

View File

@@ -9,16 +9,30 @@ namespace StellaOps.Authority;
internal sealed class AuthorityPluginRegistry : IAuthorityPluginRegistry
{
private readonly IReadOnlyDictionary<string, AuthorityPluginContext> registry;
private volatile IReadOnlyDictionary<string, AuthorityPluginContext> registry;
private volatile IReadOnlyCollection<AuthorityPluginContext> plugins;
public AuthorityPluginRegistry(IEnumerable<AuthorityPluginContext> contexts)
{
registry = contexts.ToDictionary(c => c.Manifest.Name, StringComparer.OrdinalIgnoreCase);
Plugins = registry.Values.ToArray();
var dict = contexts.ToDictionary(c => c.Manifest.Name, StringComparer.OrdinalIgnoreCase);
registry = dict;
plugins = dict.Values.ToArray();
}
public IReadOnlyCollection<AuthorityPluginContext> Plugins { get; }
public IReadOnlyCollection<AuthorityPluginContext> Plugins => plugins;
public bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context)
=> registry.TryGetValue(name, out context);
/// <summary>
/// Atomically replaces the plugin context set. Callers are responsible for
/// ensuring that downstream registries (e.g. identity-provider registry) are
/// rebuilt after this call.
/// </summary>
internal void Reload(IEnumerable<AuthorityPluginContext> contexts)
{
var dict = contexts.ToDictionary(c => c.Manifest.Name, StringComparer.OrdinalIgnoreCase);
plugins = dict.Values.ToArray();
registry = dict;
}
}

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -1736,6 +1737,50 @@ if (authorityOptions.Bootstrap.Enabled)
return Results.Problem("Failed to rotate ack token key.");
}
});
bootstrapGroup.MapPost("/plugins/reload", (
IAuthorityPluginRegistry pluginRegistry,
IAuthorityIdentityProviderRegistry identityProviderRegistry,
IOptions<StellaOpsAuthorityOptions> optionsAccessor,
IWebHostEnvironment environment,
ILogger<AuthorityPluginRegistry> reloadLogger) =>
{
try
{
var opts = optionsAccessor.Value;
var reloadedContexts = AuthorityPluginConfigurationLoader
.Load(opts, environment.ContentRootPath)
.ToArray();
if (pluginRegistry is AuthorityPluginRegistry reloadable)
{
reloadable.Reload(reloadedContexts);
reloadLogger.LogInformation(
"Plugin registry reloaded with {Count} context(s).",
reloadedContexts.Length);
}
if (identityProviderRegistry is AuthorityIdentityProviderRegistry idpReloadable)
{
idpReloadable.Rebuild();
reloadLogger.LogInformation(
"Identity provider registry rebuilt with {Count} provider(s).",
idpReloadable.Providers.Count);
}
return Results.Ok(new
{
reloaded = true,
pluginContexts = reloadedContexts.Length,
identityProviders = identityProviderRegistry.Providers.Count
});
}
catch (Exception ex)
{
reloadLogger.LogError(ex, "Plugin reload failed.");
return Results.Problem("Plugin reload failed: " + ex.Message);
}
});
}
app.UseSerilogRequestLogging(options =>

View File

@@ -3,6 +3,7 @@ using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Persistence.Sessions;
using System.Collections.Concurrent;
using System.Threading;
using static StellaOps.Localization.T;
namespace StellaOps.Authority.Persistence.InMemory.Stores;
@@ -716,7 +717,7 @@ public sealed class InMemoryRevocationExportStateStore : IAuthorityRevocationExp
{
if (state.Sequence != expectedSequence)
{
throw new InvalidOperationException($"Revocation export sequence mismatch. Expected {expectedSequence}, current {state.Sequence}.");
throw new InvalidOperationException(_t("auth.persistence.revocation_sequence_mismatch", expectedSequence, state.Sequence));
}
state = new AuthorityRevocationExportStateDocument

View File

@@ -1,15 +1,15 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Authority.Persistence.EfCore.CompiledModels;
using StellaOps.Authority.Persistence.EfCore.Context;
namespace StellaOps.Authority.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="AuthorityDbContext"/> instances.
/// Uses the static compiled model when schema matches the default; falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// Always uses reflection-based model building from <see cref="AuthorityDbContext.OnModelCreating"/>.
/// When a real compiled model is generated via <c>dotnet ef dbcontext optimize</c>,
/// re-enable UseModel() here.
/// </summary>
internal static class AuthorityDbContextFactory
{
@@ -22,12 +22,6 @@ internal static class AuthorityDbContextFactory
var optionsBuilder = new DbContextOptionsBuilder<AuthorityDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, AuthorityDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(AuthorityDbContextModel.Instance);
}
return new AuthorityDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -109,7 +109,7 @@ public sealed class ApiKeyRepository : IApiKeyRepository
await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE authority.api_keys SET last_used_at = NOW() WHERE tenant_id = {0} AND id = {1}",
tenantId, id,
[tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -123,7 +123,7 @@ public sealed class ApiKeyRepository : IApiKeyRepository
UPDATE authority.api_keys SET status = 'revoked', revoked_at = NOW(), revoked_by = {0}
WHERE tenant_id = {1} AND id = {2} AND status = 'active'
""",
revokedBy, tenantId, id,
[revokedBy, tenantId, id],
cancellationToken).ConfigureAwait(false);
}

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Persistence.EfCore.Models;
using StellaOps.Authority.Persistence.Postgres.Models;
using static StellaOps.Localization.T;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
@@ -58,7 +59,7 @@ public sealed class RevocationExportStateRepository
if (affected == 0)
{
throw new InvalidOperationException($"Revocation export state update rejected. Expected sequence {expectedSequence}.");
throw new InvalidOperationException(_t("auth.persistence.revocation_update_rejected", expectedSequence));
}
}

View File

@@ -119,7 +119,7 @@ public sealed class SessionRepository : ISessionRepository
await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE authority.sessions SET last_activity_at = NOW() WHERE tenant_id = {0} AND id = {1} AND ended_at IS NULL",
tenantId, id,
[tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -133,7 +133,7 @@ public sealed class SessionRepository : ISessionRepository
UPDATE authority.sessions SET ended_at = NOW(), end_reason = {0}
WHERE tenant_id = {1} AND id = {2} AND ended_at IS NULL
""",
reason, tenantId, id,
[reason, tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -147,7 +147,7 @@ public sealed class SessionRepository : ISessionRepository
UPDATE authority.sessions SET ended_at = NOW(), end_reason = {0}
WHERE tenant_id = {1} AND user_id = {2} AND ended_at IS NULL
""",
reason, tenantId, userId,
[reason, tenantId, userId],
cancellationToken).ConfigureAwait(false);
}
@@ -158,6 +158,7 @@ public sealed class SessionRepository : ISessionRepository
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM authority.sessions WHERE expires_at < NOW() - INTERVAL '30 days'",
[],
cancellationToken).ConfigureAwait(false);
}

View File

@@ -106,7 +106,7 @@ public sealed class TokenRepository : ITokenRepository
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = {0}
WHERE tenant_id = {1} AND id = {2} AND revoked_at IS NULL
""",
revokedBy, tenantId, id,
[revokedBy, tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -120,7 +120,7 @@ public sealed class TokenRepository : ITokenRepository
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = {0}
WHERE tenant_id = {1} AND user_id = {2} AND revoked_at IS NULL
""",
revokedBy, tenantId, userId,
[revokedBy, tenantId, userId],
cancellationToken).ConfigureAwait(false);
}
@@ -131,6 +131,7 @@ public sealed class TokenRepository : ITokenRepository
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM authority.tokens WHERE expires_at < NOW() - INTERVAL '7 days'",
[],
cancellationToken).ConfigureAwait(false);
}
@@ -251,9 +252,7 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = {0}, replaced_by = {1}
WHERE tenant_id = {2} AND id = {3} AND revoked_at IS NULL
""",
revokedBy,
(object?)replacedBy ?? DBNull.Value,
tenantId, id,
[revokedBy, (object?)replacedBy ?? DBNull.Value, tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -267,7 +266,7 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = {0}
WHERE tenant_id = {1} AND user_id = {2} AND revoked_at IS NULL
""",
revokedBy, tenantId, userId,
[revokedBy, tenantId, userId],
cancellationToken).ConfigureAwait(false);
}
@@ -278,6 +277,7 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM authority.refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'",
[],
cancellationToken).ConfigureAwait(false);
}

View File

@@ -191,7 +191,7 @@ public sealed class UserRepository : IUserRepository
SET password_hash = {0}, password_salt = {1}, password_changed_at = NOW()
WHERE tenant_id = {2} AND id = {3}
""",
passwordHash, passwordSalt, tenantId, userId,
[passwordHash, passwordSalt, tenantId, userId],
cancellationToken).ConfigureAwait(false);
return rows > 0;
@@ -238,7 +238,7 @@ public sealed class UserRepository : IUserRepository
SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NOW()
WHERE tenant_id = {0} AND id = {1}
""",
tenantId, userId,
[tenantId, userId],
cancellationToken).ConfigureAwait(false);
}

View File

@@ -4,6 +4,7 @@ using StellaOps.Authority.Persistence.EfCore.Models;
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using static StellaOps.Localization.T;
namespace StellaOps.Authority.Persistence.Postgres;
@@ -61,14 +62,17 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
signature_base64 = EXCLUDED.signature_base64,
rekor_log_id = EXCLUDED.rekor_log_id
""",
manifest.ManifestId, manifest.Tenant, manifest.AssetDigest, manifest.VulnerabilityId,
JsonSerializer.Serialize(manifest.Inputs, s_jsonOptions),
StatusToString(manifest.Result.Status),
manifest.Result.Confidence,
JsonSerializer.Serialize(manifest.Result, s_jsonOptions),
manifest.PolicyHash, manifest.LatticeVersion, manifest.EvaluatedAt, manifest.ManifestDigest,
(object?)manifest.SignatureBase64 ?? DBNull.Value,
(object?)manifest.RekorLogId ?? DBNull.Value,
new object[]
{
manifest.ManifestId, manifest.Tenant, manifest.AssetDigest, manifest.VulnerabilityId,
JsonSerializer.Serialize(manifest.Inputs, s_jsonOptions),
StatusToString(manifest.Result.Status),
manifest.Result.Confidence,
JsonSerializer.Serialize(manifest.Result, s_jsonOptions),
manifest.PolicyHash, manifest.LatticeVersion, manifest.EvaluatedAt, manifest.ManifestDigest,
(object?)manifest.SignatureBase64 ?? DBNull.Value,
(object?)manifest.RekorLogId ?? DBNull.Value,
},
ct).ConfigureAwait(false);
return manifest;
@@ -226,9 +230,9 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
private static VerdictManifest ToManifest(VerdictManifestEfEntity ef)
{
var inputs = JsonSerializer.Deserialize<VerdictInputs>(ef.InputsJson, s_jsonOptions)
?? throw new InvalidOperationException("Failed to deserialize inputs");
?? throw new InvalidOperationException(_t("auth.persistence.deserialize_inputs_failed"));
var result = JsonSerializer.Deserialize<VerdictResult>(ef.ResultJson, s_jsonOptions)
?? throw new InvalidOperationException("Failed to deserialize result");
?? throw new InvalidOperationException(_t("auth.persistence.deserialize_result_failed"));
return new VerdictManifest
{

View File

@@ -35,6 +35,7 @@
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Core\StellaOps.Authority.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
</ItemGroup>
</Project>