Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ COPY --from=build --chown=appuser:appgroup /app/publish .
# Writable directory for SQLite database and local storage
RUN mkdir -p /app/data /app/storage && chown appuser:appgroup /app/data /app/storage

# Default the SQLite database into the writable /app/data dir. The app's built-in
# default is "Data Source=app.db", which resolves to the root-owned /app workdir
# that the non-root appuser cannot write — a bare `docker run` of this image would
# otherwise crash at startup with "SQLite Error 14: unable to open database file".
# docker-compose overrides this (Database__Provider=PostgreSQL + its own connection
# string), so the Postgres deployment path is unaffected.
ENV Database__DefaultConnection="Data Source=/app/data/app.db"

# Deployment version for JS/CSS cache-busting and Inertia stale-version detection.
# Left empty by default so InertiaMiddleware.GetVersion() falls back to the entry
# assembly's last-write timestamp (yyyyMMddHHmmss), which changes on every publish
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ public static bool IsMatch(string claim, string requirement)
if (string.Equals(claim, requirement, StringComparison.Ordinal))
return true;

// Wildcard: claim ends with ".*"
// Wildcard: claim ends with ".*" — match the "Prefix." span without allocating.
if (
claim.Length > 2
&& claim[^1] == '*'
&& claim[^2] == '.'
&& requirement.StartsWith(claim[..^1], StringComparison.Ordinal)
&& requirement.AsSpan().StartsWith(claim.AsSpan(0, claim.Length - 1), StringComparison.Ordinal)
)
{
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace SimpleModule.Core.Authorization;
public sealed class PermissionRegistryBuilder
{
private readonly Dictionary<string, List<string>> _byModule = new();
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);

public void AddPermissions<T>()
where T : class
Expand All @@ -28,16 +29,18 @@ public void AddPermission(string permission)
var dotIndex = permission.IndexOf('.', StringComparison.Ordinal);
var module = dotIndex >= 0 ? permission[..dotIndex] : "Global";

if (!_seen.Add(permission))
{
return;
}

if (!_byModule.TryGetValue(module, out var list))
{
list = [];
_byModule[module] = list;
}

if (!list.Contains(permission))
{
list.Add(permission);
}
list.Add(permission);
}

public PermissionRegistry Build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ CancellationToken cancellationToken
ErrorMessages.ValidationErrorTitle,
ve.Errors
),
// ArgumentNullException almost always signals an internal bug (a null
// reaching a guard clause), not bad client input — let it fall through to
// the 500 branch so it logs at Error and doesn't leak the parameter name.
ArgumentNullException => (
StatusCodes.Status500InternalServerError,
ErrorMessages.InternalServerErrorTitle,
null
),
ArgumentException => (
StatusCodes.Status400BadRequest,
ErrorMessages.ValidationErrorTitle,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using SimpleModule.Core.Authorization;

Expand All @@ -14,6 +16,17 @@ public static class ClaimsPrincipalExtensions
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
}

/// <summary>
/// Gets the user's role values, supporting both OpenIddict/JWT ("role", emitted when
/// <c>MapInboundClaims = false</c>) and ASP.NET Identity (<see cref="ClaimTypes.Role"/>).
/// </summary>
public static IEnumerable<string> GetRoles(this ClaimsPrincipal principal)
{
return principal
.Claims.Where(c => c.Type == "role" || c.Type == ClaimTypes.Role)
.Select(c => c.Value);
}

/// <summary>
/// Returns null for Admin users (no scoping — sees all resources), or the user ID for regular users.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SimpleModule.Core.Extensions;

namespace SimpleModule.Core.FeatureFlags;

Expand All @@ -22,8 +22,8 @@ public static TBuilder RequireFeature<TBuilder>(this TBuilder builder, string fe
return await next(context);
}

var userId = context.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
var roles = context.HttpContext.User.FindAll(ClaimTypes.Role).Select(c => c.Value);
var userId = context.HttpContext.User.GetUserId();
var roles = context.HttpContext.User.GetRoles();

var isEnabled = await featureFlagService.IsEnabledAsync(featureName, userId, roles);

Expand Down
29 changes: 24 additions & 5 deletions framework/SimpleModule.Core/Inertia/InertiaMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

Expand Down Expand Up @@ -63,11 +64,29 @@ private static string GetVersion()
// This changes on every recompile/publish, ensuring cache-busting without manual config.
var entryAssembly =
System.Reflection.Assembly.GetEntryAssembly() ?? typeof(InertiaMiddleware).Assembly;
var buildTime = File.GetLastWriteTimeUtc(entryAssembly.Location);
return buildTime.ToString(
"yyyyMMddHHmmss",
System.Globalization.CultureInfo.InvariantCulture
);

// Assembly.Location is empty for single-file–published apps; File.GetLastWriteTimeUtc("")
// would throw and abort all Inertia rendering (this runs in a static initializer). Fall
// back to the informational/assembly version so the app still starts.
var location = entryAssembly.Location;
if (!string.IsNullOrEmpty(location) && File.Exists(location))
{
var buildTime = File.GetLastWriteTimeUtc(location);
return buildTime.ToString(
"yyyyMMddHHmmss",
System.Globalization.CultureInfo.InvariantCulture
);
}

var informationalVersion = entryAssembly
.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
.FirstOrDefault()
?.InformationalVersion;

return !string.IsNullOrEmpty(informationalVersion)
? informationalVersion
: entryAssembly.GetName().Version?.ToString() ?? "1.0.0";
}

private static string GetEncodedUrl(this HttpRequest request)
Expand Down
98 changes: 72 additions & 26 deletions framework/SimpleModule.Core/Inertia/InertiaResult.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System.Buffers;
using System.Linq;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
Expand Down Expand Up @@ -29,28 +32,18 @@ public async Task ExecuteAsync(HttpContext httpContext)
{
var options = GetSerializerOptions(httpContext);
var sharedData = httpContext.RequestServices.GetService<InertiaSharedData>();
var mergedProps = MergeProps(_props, sharedData, options);

var pageData = new
{
component = _component,
props = mergedProps,
url = httpContext.Request.Path + httpContext.Request.QueryString,
version = InertiaMiddleware.Version,
};
var url = httpContext.Request.Path + httpContext.Request.QueryString;
var pageJson = SerializePage(_component, _props, sharedData, url, options);

if (httpContext.Request.IsInertia())
{
httpContext.Response.Headers[InertiaHttpExtensions.InertiaHeader] = "true";
httpContext.Response.Headers["Vary"] = InertiaHttpExtensions.InertiaHeader;
httpContext.Response.ContentType = "application/json";
var json = JsonSerializer.Serialize(pageData, options);
await httpContext.Response.WriteAsync(json);
await httpContext.Response.WriteAsync(pageJson);
return;
}

var pageJson = JsonSerializer.Serialize(pageData, options);

var renderer = httpContext.RequestServices.GetRequiredService<IInertiaPageRenderer>();
await renderer.RenderPageAsync(httpContext, pageJson);
}
Expand Down Expand Up @@ -84,36 +77,89 @@ private static JsonSerializerOptions GetSerializerOptions(HttpContext httpContex
return fallback;
}

private static object MergeProps(
/// <summary>
/// Serializes the Inertia page envelope in a single pass. When there is no shared
/// data, endpoint props stream straight to the output with no intermediate DOM.
/// When shared data is present, endpoint props must be materialized once (to know
/// their keys), then shared data and props are written into one object — endpoint
/// props keep priority (shared keys they define are skipped), so no key is emitted
/// twice.
/// </summary>
private static string SerializePage(
string component,
object? props,
InertiaSharedData? sharedData,
string url,
JsonSerializerOptions options
)
{
if (sharedData is null || sharedData.All.Count == 0)
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer))
{
return props ?? new { };
writer.WriteStartObject();
writer.WriteString("component", component);

writer.WritePropertyName("props");
if (sharedData is null || sharedData.All.Count == 0)
{
JsonSerializer.Serialize(writer, props ?? EmptyProps, options);
}
else
{
WriteMergedProps(writer, props, sharedData, options);
}

writer.WriteString("url", url);
writer.WriteString("version", InertiaMiddleware.Version);
writer.WriteEndObject();
}

var result = new Dictionary<string, object?>();
return Encoding.UTF8.GetString(buffer.WrittenSpan);
}

// Add shared data first (lower priority)
private static void WriteMergedProps(
Utf8JsonWriter writer,
object? props,
InertiaSharedData sharedData,
JsonSerializerOptions options
)
{
// Endpoint props are materialized once so their top-level keys are known;
// this is unavoidable for a correct merge but happens a single time.
using var propsDoc =
props is null ? null : JsonSerializer.SerializeToDocument(props, options);

var propKeys =
propsDoc is null
? null
: new HashSet<string>(
propsDoc.RootElement.EnumerateObject().Select(p => p.Name),
StringComparer.Ordinal
);

writer.WriteStartObject();

// Shared data (lower priority) — skip any key the endpoint props also define.
foreach (var kvp in sharedData.All)
{
result[kvp.Key] = kvp.Value;
if (propKeys is not null && propKeys.Contains(kvp.Key))
continue;

writer.WritePropertyName(kvp.Key);
JsonSerializer.Serialize(writer, kvp.Value, options);
}

// Add endpoint props (higher priority — overwrites shared data)
// Use JSON round-trip to merge endpoint props into shared data
if (props is not null)
// Endpoint props (higher priority).
if (propsDoc is not null)
{
var json = JsonSerializer.SerializeToElement(props, options);
foreach (var property in json.EnumerateObject())
foreach (var property in propsDoc.RootElement.EnumerateObject())
{
result[property.Name] = property.Value;
property.WriteTo(writer);
}
}

return result;
writer.WriteEndObject();
}

private static readonly object EmptyProps = new();
}
17 changes: 17 additions & 0 deletions framework/SimpleModule.Database/ITenantScopedDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace SimpleModule.Database;

/// <summary>
/// Implemented by a module <c>DbContext</c> that applies multi-tenant query filters via
/// <see cref="ModuleModelBuilderExtensions.ApplyMultiTenantFilters{TContext}"/>.
/// <para>
/// The filter references this property on the <em>executing</em> context instance, which EF
/// Core re-evaluates on every query. Expose the request's current tenant here (e.g. from an
/// injected scoped <see cref="SimpleModule.Core.Entities.ITenantContext"/>) so each request is
/// filtered by its own tenant, rather than a value frozen into the cached model.
/// </para>
/// </summary>
public interface ITenantScopedDbContext
{
/// <summary>The tenant id for the current request, or <c>null</c> when no tenant is set.</summary>
string? CurrentTenantId { get; }
}
Loading
Loading