Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Infrastructure.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Base DbContext for StellaOps modules with schema isolation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derived contexts should:
|
||||
/// 1. Override <see cref="SchemaName"/> to specify the module's PostgreSQL schema
|
||||
/// 2. Define DbSet properties for module entities
|
||||
/// 3. Configure compiled model in OnConfiguring if using compiled models
|
||||
/// </remarks>
|
||||
public abstract class StellaOpsDbContextBase : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// PostgreSQL schema name for this module's tables.
|
||||
/// </summary>
|
||||
protected abstract string SchemaName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new DbContext with the specified options.
|
||||
/// </summary>
|
||||
protected StellaOpsDbContextBase(DbContextOptions options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Set the default schema for all entities
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a raw SQL query and returns the result.
|
||||
/// Use for complex queries that don't map well to EF Core (CTEs, window functions, etc.).
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Result type.</typeparam>
|
||||
/// <param name="sql">Parameterized SQL query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Query results.</returns>
|
||||
public async Task<List<T>> RawSqlQueryAsync<T>(
|
||||
FormattableString sql,
|
||||
CancellationToken cancellationToken = default) where T : class
|
||||
{
|
||||
return await Database.SqlQuery<T>(sql).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a raw SQL command (INSERT, UPDATE, DELETE).
|
||||
/// Use for bulk operations or complex mutations.
|
||||
/// </summary>
|
||||
/// <param name="sql">Parameterized SQL command.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of rows affected.</returns>
|
||||
public async Task<int> RawSqlExecuteAsync(
|
||||
FormattableString sql,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await Database.ExecuteSqlInterpolatedAsync(sql, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.EfCore.Interceptors;
|
||||
using StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
namespace StellaOps.Infrastructure.EfCore.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering StellaOps EF Core DbContexts with tenant isolation.
|
||||
/// </summary>
|
||||
public static class DbContextServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a StellaOps DbContext with tenant connection interceptor.
|
||||
/// </summary>
|
||||
/// <typeparam name="TContext">DbContext type to register.</typeparam>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
|
||||
/// <param name="configureOptions">Optional additional configuration.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStellaOpsDbContext<TContext>(
|
||||
this IServiceCollection services,
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
Action<DbContextOptionsBuilder>? configureOptions = null)
|
||||
where TContext : DbContext
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
|
||||
services.AddDbContext<TContext>((sp, options) =>
|
||||
{
|
||||
options.UseNpgsql(connectionString, npgsql =>
|
||||
{
|
||||
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
|
||||
});
|
||||
|
||||
// Add tenant connection interceptor if tenant accessor is registered
|
||||
var tenantAccessor = sp.GetService<ITenantContextAccessor>();
|
||||
if (tenantAccessor != null)
|
||||
{
|
||||
var logger = sp.GetService<ILogger<TenantConnectionInterceptor>>();
|
||||
options.AddInterceptors(new TenantConnectionInterceptor(tenantAccessor, schemaName, logger));
|
||||
}
|
||||
|
||||
// Enable detailed error messages in development
|
||||
options.EnableDetailedErrors();
|
||||
options.EnableSensitiveDataLogging(false); // Keep false for security
|
||||
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a StellaOps DbContext with compiled model and tenant connection interceptor.
|
||||
/// </summary>
|
||||
/// <typeparam name="TContext">DbContext type to register.</typeparam>
|
||||
/// <typeparam name="TCompiledModel">Compiled model type (implements IModel).</typeparam>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
|
||||
/// <param name="compiledModelInstance">Instance of the compiled model.</param>
|
||||
/// <param name="configureOptions">Optional additional configuration.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStellaOpsDbContextWithCompiledModel<TContext, TCompiledModel>(
|
||||
this IServiceCollection services,
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
TCompiledModel compiledModelInstance,
|
||||
Action<DbContextOptionsBuilder>? configureOptions = null)
|
||||
where TContext : DbContext
|
||||
where TCompiledModel : class, IModel
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
ArgumentNullException.ThrowIfNull(compiledModelInstance);
|
||||
|
||||
services.AddDbContext<TContext>((sp, options) =>
|
||||
{
|
||||
options.UseNpgsql(connectionString, npgsql =>
|
||||
{
|
||||
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
|
||||
});
|
||||
|
||||
// Use compiled model for faster startup
|
||||
options.UseModel(compiledModelInstance);
|
||||
|
||||
// Add tenant connection interceptor if tenant accessor is registered
|
||||
var tenantAccessor = sp.GetService<ITenantContextAccessor>();
|
||||
if (tenantAccessor != null)
|
||||
{
|
||||
var logger = sp.GetService<ILogger<TenantConnectionInterceptor>>();
|
||||
options.AddInterceptors(new TenantConnectionInterceptor(tenantAccessor, schemaName, logger));
|
||||
}
|
||||
|
||||
// Enable detailed error messages in development
|
||||
options.EnableDetailedErrors();
|
||||
options.EnableSensitiveDataLogging(false);
|
||||
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a DbContext factory for creating DbContext instances.
|
||||
/// Useful for background services and worker scenarios.
|
||||
/// </summary>
|
||||
/// <typeparam name="TContext">DbContext type to register.</typeparam>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
|
||||
/// <param name="configureOptions">Optional additional configuration.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStellaOpsDbContextFactory<TContext>(
|
||||
this IServiceCollection services,
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
Action<DbContextOptionsBuilder>? configureOptions = null)
|
||||
where TContext : DbContext
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
|
||||
services.AddDbContextFactory<TContext>((sp, options) =>
|
||||
{
|
||||
options.UseNpgsql(connectionString, npgsql =>
|
||||
{
|
||||
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
|
||||
});
|
||||
|
||||
// Add tenant connection interceptor if tenant accessor is registered
|
||||
var tenantAccessor = sp.GetService<ITenantContextAccessor>();
|
||||
if (tenantAccessor != null)
|
||||
{
|
||||
var logger = sp.GetService<ILogger<TenantConnectionInterceptor>>();
|
||||
options.AddInterceptors(new TenantConnectionInterceptor(tenantAccessor, schemaName, logger));
|
||||
}
|
||||
|
||||
options.EnableDetailedErrors();
|
||||
options.EnableSensitiveDataLogging(false);
|
||||
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
namespace StellaOps.Infrastructure.EfCore.Interceptors;
|
||||
|
||||
/// <summary>
|
||||
/// Sets tenant context and session configuration on each PostgreSQL connection.
|
||||
/// Mirrors DataSourceBase.ConfigureSessionAsync() behavior for EF Core contexts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Session settings applied:
|
||||
/// - TIME ZONE 'UTC' for deterministic timestamps
|
||||
/// - app.current_tenant / app.tenant_id for RLS
|
||||
/// - search_path to module schema
|
||||
/// </remarks>
|
||||
public sealed class TenantConnectionInterceptor : DbConnectionInterceptor
|
||||
{
|
||||
private readonly ITenantContextAccessor _tenantAccessor;
|
||||
private readonly string _schemaName;
|
||||
private readonly ILogger<TenantConnectionInterceptor>? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new tenant connection interceptor.
|
||||
/// </summary>
|
||||
/// <param name="tenantAccessor">Provider for current tenant context.</param>
|
||||
/// <param name="schemaName">PostgreSQL schema name for search_path.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
public TenantConnectionInterceptor(
|
||||
ITenantContextAccessor tenantAccessor,
|
||||
string schemaName,
|
||||
ILogger<TenantConnectionInterceptor>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenantAccessor);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
|
||||
_tenantAccessor = tenantAccessor;
|
||||
_schemaName = schemaName;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task ConnectionOpenedAsync(
|
||||
DbConnection connection,
|
||||
ConnectionEndEventData eventData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (connection is not NpgsqlConnection npgsqlConnection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = _tenantAccessor.TenantId ?? "_system";
|
||||
|
||||
try
|
||||
{
|
||||
await ConfigureSessionAsync(npgsqlConnection, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex,
|
||||
"Failed to configure PostgreSQL session for tenant {TenantId} in schema {Schema}",
|
||||
tenantId, _schemaName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void ConnectionOpened(
|
||||
DbConnection connection,
|
||||
ConnectionEndEventData eventData)
|
||||
{
|
||||
if (connection is not NpgsqlConnection npgsqlConnection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = _tenantAccessor.TenantId ?? "_system";
|
||||
|
||||
try
|
||||
{
|
||||
ConfigureSessionAsync(npgsqlConnection, tenantId, CancellationToken.None)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex,
|
||||
"Failed to configure PostgreSQL session for tenant {TenantId} in schema {Schema}",
|
||||
tenantId, _schemaName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfigureSessionAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Combine all session configuration into a single command for efficiency
|
||||
var sql = $"""
|
||||
SET TIME ZONE 'UTC';
|
||||
SELECT set_config('app.current_tenant', $1, false),
|
||||
set_config('app.tenant_id', $1, false);
|
||||
SET search_path TO {_schemaName}, public;
|
||||
""";
|
||||
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.Parameters.AddWithValue(tenantId);
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug(
|
||||
"Configured PostgreSQL session: tenant={TenantId}, schema={Schema}",
|
||||
tenantId, _schemaName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Infrastructure.EfCore</RootNamespace>
|
||||
<AssemblyName>StellaOps.Infrastructure.EfCore</AssemblyName>
|
||||
<Description>Shared EF Core infrastructure for StellaOps modules with tenant isolation support</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant context accessor using AsyncLocal for tenant propagation across async calls.
|
||||
/// Use this for message handlers, background jobs, and other async scenarios.
|
||||
/// </summary>
|
||||
public sealed class AsyncLocalTenantContextAccessor : ITenantContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<string?> _tenantId = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId => _tenantId.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current tenant ID for the async scope.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID to set.</param>
|
||||
public static void SetTenantId(string? tenantId)
|
||||
{
|
||||
_tenantId.Value = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the current tenant ID.
|
||||
/// </summary>
|
||||
public static void ClearTenantId()
|
||||
{
|
||||
_tenantId.Value = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that sets the tenant ID and clears it on dispose.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID to set.</param>
|
||||
/// <returns>Disposable scope.</returns>
|
||||
public static IDisposable CreateScope(string tenantId)
|
||||
{
|
||||
return new TenantScope(tenantId);
|
||||
}
|
||||
|
||||
private sealed class TenantScope : IDisposable
|
||||
{
|
||||
private readonly string? _previousTenantId;
|
||||
|
||||
public TenantScope(string tenantId)
|
||||
{
|
||||
_previousTenantId = _tenantId.Value;
|
||||
_tenantId.Value = tenantId;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tenantId.Value = _previousTenantId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current tenant context.
|
||||
/// Implement this interface for your specific authentication mechanism (headers, claims, etc.).
|
||||
/// </summary>
|
||||
public interface ITenantContextAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current tenant ID, or null if not available.
|
||||
/// Returns "_system" for system/admin operations.
|
||||
/// </summary>
|
||||
string? TenantId { get; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant context accessor that always returns "_system".
|
||||
/// Use for background services, migrations, and admin operations.
|
||||
/// </summary>
|
||||
public sealed class SystemTenantContextAccessor : ITenantContextAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static readonly SystemTenantContextAccessor Instance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId => "_system";
|
||||
}
|
||||
Reference in New Issue
Block a user