Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.13",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `F

To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted) and `JobQueue__WorkerCount`, and from an optional `appsettings.json` next to the binary.

## Database migrations

Each provider has its own `DbContext` subclass and migrations folder (ADR-0005). After changing the model, add a migration for both; no database needs to be running, the design-time factories build the model on their own.

```bash
dotnet tool restore
dotnet ef migrations add <Name> --project src/FluentCMS.Infrastructure --context SqliteFluentCmsDbContext --output-dir Persistence/Migrations/Sqlite
dotnet ef migrations add <Name> --project src/FluentCMS.Infrastructure --context PostgreSqlFluentCmsDbContext --output-dir Persistence/Migrations/PostgreSql
```

Migrations apply automatically when the host starts. Every migration needs a working `Down`, since upgrades must be reversible one version back (OPS-04).

## Extension points

Every pluggable part of the product is an interface in `FluentCMS.Core` marked `[ExtensionPoint]`. Implementations register through dependency injection, either explicitly with `AddFluentCmsExtension<TContract, TImplementation>()` or by discovery with `AddFluentCmsExtensions(assembly)`, which picks up every concrete class implementing an extension-point interface in that assembly. Both paths produce the same `IExtensionCatalog` entry and the same `IEnumerable<TContract>` resolution, so built-ins under `FluentCMS.Core.Builtin` and future plugins look identical to the rest of the product. Each interface is documented where it is defined, together with the conformance tests an implementation must pass.
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
</PropertyGroup>

<ItemGroup Label="Runtime">
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.13" />
Expand Down
36 changes: 36 additions & 0 deletions src/FluentCMS.Core/Identity/ClaimsInitiator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Security.Claims;

namespace FluentCMS.Core.Identity;

public static class ClaimsInitiator
{
public static Initiator? From(ClaimsPrincipal? principal)
{
if (principal?.Identity?.IsAuthenticated != true)
{
return null;
}

var displayName = Value(principal, FluentCmsClaimTypes.DisplayName)
?? principal.Identity.Name
?? string.Empty;

// The agent claim wins over any user id claim, so an agent can never be recorded as a person (SEC-01, AUD-01).
if (Guid.TryParse(Value(principal, FluentCmsClaimTypes.AgentCredentialId), out var credentialId))
{
return new AgentInitiator(credentialId, displayName);
}

if (Guid.TryParse(Value(principal, ClaimTypes.NameIdentifier), out var userId))
{
return new UserInitiator(userId, displayName);
}

return null;
}

private static string? Value(ClaimsPrincipal principal, string claimType)
{
return principal.FindFirst(claimType)?.Value;
}
}
9 changes: 9 additions & 0 deletions src/FluentCMS.Core/Identity/FluentCmsClaimTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace FluentCMS.Core.Identity;

public static class FluentCmsClaimTypes
{
public const string DisplayName = "fluentcms:display_name";

// Present only on principals authenticated with an agent credential (F-MCP).
public const string AgentCredentialId = "fluentcms:agent_credential_id";
}
7 changes: 7 additions & 0 deletions src/FluentCMS.Core/Identity/FluentCmsRoles.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace FluentCMS.Core.Identity;

public static class FluentCmsRoles
{
// The only role in M1 (D23); rule author, approver and fact owner arrive in M2.
public const string Administrator = "Administrator";
}
7 changes: 7 additions & 0 deletions src/FluentCMS.Core/Identity/ICurrentInitiator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace FluentCMS.Core.Identity;

public interface ICurrentInitiator
{
// Null when nobody is signed in or the host runs without a caller, such as a scheduled job.
Initiator? Current { get; }
}
11 changes: 11 additions & 0 deletions src/FluentCMS.Core/Identity/IFirstRunSetup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using FluentCMS.Core.Results;

namespace FluentCMS.Core.Identity;

// A fresh install asks for its first administrator before anything else is reachable (F-USR acceptance).
public interface IFirstRunSetup
{
Task<bool> NeedsFirstAdministratorAsync(CancellationToken cancellationToken = default);

Task<Result<Guid>> CreateFirstAdministratorAsync(NewUser user, CancellationToken cancellationToken = default);
}
13 changes: 13 additions & 0 deletions src/FluentCMS.Core/Identity/IUserDirectory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentCMS.Core.Results;

namespace FluentCMS.Core.Identity;

public interface IUserDirectory
{
Task<IReadOnlyList<UserSummary>> ListAsync(CancellationToken cancellationToken = default);

// Every user created in M1 is an administrator (D23).
Task<Result<Guid>> CreateAsync(NewUser user, CancellationToken cancellationToken = default);

Task<Result> DisableAsync(Guid userId, CancellationToken cancellationToken = default);
}
8 changes: 8 additions & 0 deletions src/FluentCMS.Core/Identity/Initiator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace FluentCMS.Core.Identity;

// Who caused a change: a person or an agent, never confusable in code or in the audit log (ADR-0009, AUD-01).
public abstract record Initiator(string DisplayName);

public sealed record UserInitiator(Guid UserId, string DisplayName) : Initiator(DisplayName);

public sealed record AgentInitiator(Guid CredentialId, string DisplayName) : Initiator(DisplayName);
3 changes: 3 additions & 0 deletions src/FluentCMS.Core/Identity/NewUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace FluentCMS.Core.Identity;

public sealed record NewUser(string Email, string DisplayName, string Password);
3 changes: 3 additions & 0 deletions src/FluentCMS.Core/Identity/UserSummary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace FluentCMS.Core.Identity;

public sealed record UserSummary(Guid Id, string Email, string DisplayName, bool IsDisabled, IReadOnlyList<string> Roles);
3 changes: 3 additions & 0 deletions src/FluentCMS.Core/Results/Error.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace FluentCMS.Core.Results;

public sealed record Error(string Code, string Message);
63 changes: 63 additions & 0 deletions src/FluentCMS.Core/Results/Result.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace FluentCMS.Core.Results;

// Application services return results for expected failures, so tools and screens can say what to change (ADR-0004, UX-03).
public sealed class Result
{
private Result(IReadOnlyList<Error> errors)
{
Errors = errors;
}

public bool IsSuccess => Errors.Count == 0;

public IReadOnlyList<Error> Errors { get; }

public static Result Success()
{
return new Result([]);
}

public static Result Failure(IEnumerable<Error> errors)
{
return new Result(errors.ToList());
}

public static Result Failure(string code, string message)
{
return Failure([new Error(code, message)]);
}
}

public sealed class Result<T>
{
private readonly T? _value;

private Result(T? value, IReadOnlyList<Error> errors)
{
_value = value;
Errors = errors;
}

public bool IsSuccess => Errors.Count == 0;

public IReadOnlyList<Error> Errors { get; }

public T Value => IsSuccess
? _value!
: throw new InvalidOperationException("The result failed: " + string.Join("; ", Errors.Select(error => error.Message)));

public static Result<T> Success(T value)
{
return new Result<T>(value, []);
}

public static Result<T> Failure(IEnumerable<Error> errors)
{
return new Result<T>(default, errors.ToList());
}

public static Result<T> Failure(string code, string message)
{
return Failure([new Error(code, message)]);
}
}
5 changes: 5 additions & 0 deletions src/FluentCMS.Host/FluentCmsHost.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using FluentCMS.Admin;
using FluentCMS.Api;
using FluentCMS.Core;
using FluentCMS.Core.Identity;
using FluentCMS.Host.Identity;
using FluentCMS.Host.Jobs;
using FluentCMS.Infrastructure;
using FluentCMS.Mcp;
Expand Down Expand Up @@ -30,6 +32,8 @@ public static WebApplication BuildWebApplication(string[] args, Action<WebApplic

ConfigureLogging(builder.Logging, logToStandardError: false);
AddFluentCms(builder.Services, builder.Configuration);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentInitiator, HttpContextInitiator>();
builder.Services.AddHealthChecks();
configure?.Invoke(builder);

Expand All @@ -48,6 +52,7 @@ public static IHost BuildMcpHost(string[] args, Stream? standardInput = null, Ac
? StandardInputSource.Console
: new StandardInputSource(() => standardInput));
builder.Services.AddHostedService<StandardInputWatcher>();
builder.Services.AddScoped<ICurrentInitiator, NoInitiator>();
configure?.Invoke(builder);

return builder.Build();
Expand Down
14 changes: 14 additions & 0 deletions src/FluentCMS.Host/Identity/CurrentInitiators.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using FluentCMS.Core.Identity;

namespace FluentCMS.Host.Identity;

internal sealed class HttpContextInitiator(IHttpContextAccessor accessor) : ICurrentInitiator
{
public Initiator? Current => ClaimsInitiator.From(accessor.HttpContext?.User);
}

// The stdio host has no caller until F-MCP authenticates agent credentials.
internal sealed class NoInitiator : ICurrentInitiator
{
public Initiator? Current => null;
}
5 changes: 5 additions & 0 deletions src/FluentCMS.Infrastructure/FluentCMS.Infrastructure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

</Project>
25 changes: 25 additions & 0 deletions src/FluentCMS.Infrastructure/Identity/FirstRunSetup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using FluentCMS.Core.Identity;
using FluentCMS.Core.Results;
using FluentCMS.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

namespace FluentCMS.Infrastructure.Identity;

internal sealed class FirstRunSetup(UserManager<FluentCmsUser> users, IUserDirectory directory) : IFirstRunSetup
{
public async Task<bool> NeedsFirstAdministratorAsync(CancellationToken cancellationToken = default)
{
return !await users.Users.AnyAsync(cancellationToken);
}

public async Task<Result<Guid>> CreateFirstAdministratorAsync(NewUser user, CancellationToken cancellationToken = default)
{
if (!await NeedsFirstAdministratorAsync(cancellationToken))
{
return Result<Guid>.Failure("already_set_up", "An administrator already exists; sign in instead.");
}

return await directory.CreateAsync(user, cancellationToken);
}
}
90 changes: 90 additions & 0 deletions src/FluentCMS.Infrastructure/Identity/UserDirectory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using FluentCMS.Core.Identity;
using FluentCMS.Core.Results;
using FluentCMS.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

namespace FluentCMS.Infrastructure.Identity;

internal sealed class UserDirectory(
UserManager<FluentCmsUser> users,
RoleManager<FluentCmsRole> roles,
TimeProvider clock) : IUserDirectory
{
// Identity has no "disabled" flag; a lockout with no end is the standard way to switch an account off.
private static readonly DateTimeOffset NoEnd = new(9999, 1, 1, 0, 0, 0, TimeSpan.Zero);

public async Task<IReadOnlyList<UserSummary>> ListAsync(CancellationToken cancellationToken = default)
{
var stored = await users.Users.OrderBy(user => user.Email).ToListAsync(cancellationToken);
var summaries = new List<UserSummary>(stored.Count);

foreach (var user in stored)
{
summaries.Add(new UserSummary(
user.Id,
user.Email ?? string.Empty,
user.DisplayName,
IsDisabled(user),
(await users.GetRolesAsync(user)).ToList()));
}

return summaries;
}

public async Task<Result<Guid>> CreateAsync(NewUser newUser, CancellationToken cancellationToken = default)
{
var user = new FluentCmsUser
{
Id = Guid.NewGuid(),
UserName = newUser.Email,
Email = newUser.Email,
DisplayName = newUser.DisplayName,
CreatedAt = clock.GetUtcNow(),
};

var created = await users.CreateAsync(user, newUser.Password);
if (!created.Succeeded)
{
return Result<Guid>.Failure(ToErrors(created));
}

if (!await roles.RoleExistsAsync(FluentCmsRoles.Administrator))
{
await roles.CreateAsync(new FluentCmsRole { Id = Guid.NewGuid(), Name = FluentCmsRoles.Administrator });
}

var assigned = await users.AddToRoleAsync(user, FluentCmsRoles.Administrator);
return assigned.Succeeded
? Result<Guid>.Success(user.Id)
: Result<Guid>.Failure(ToErrors(assigned));
}

public async Task<Result> DisableAsync(Guid userId, CancellationToken cancellationToken = default)
{
var user = await users.FindByIdAsync(userId.ToString());
if (user is null)
{
return Result.Failure("user_not_found", $"No user has the id {userId}.");
}

var enabled = await users.SetLockoutEnabledAsync(user, true);
if (!enabled.Succeeded)
{
return Result.Failure(ToErrors(enabled));
}

var locked = await users.SetLockoutEndDateAsync(user, NoEnd);
return locked.Succeeded ? Result.Success() : Result.Failure(ToErrors(locked));
}

private bool IsDisabled(FluentCmsUser user)
{
return user.LockoutEnabled && user.LockoutEnd is { } end && end > clock.GetUtcNow();
}

private static IEnumerable<Error> ToErrors(IdentityResult result)
{
return result.Errors.Select(error => new Error(error.Code, error.Description));
}
}
Loading
Loading