Add users, roles, and per-novel permissions

Introduces accounts (ASP.NET Identity + cookie auth), four global
roles (Admin/Writer/Editor/Reviewer), per-novel ownership and grants
via ProjectMember, and a service-API-key principal for the MCP server
and background import jobs. Enforcement lives in the application
services (not endpoint filters) so the embedded agent and MCP tools,
which call the same services directly, can't bypass it. Web client
gets a login page, session-aware routing, and a People section for
managing per-novel access.

Also includes prior in-flight changes from this branch (CLAUDE.md
compliance pass, dev-deploy docker-compose setup) that were
uncommitted when this feature work started.
This commit is contained in:
James Wampler
2026-08-15 22:29:33 -07:00
parent 7d8dd0c4fd
commit e598c18d67
111 changed files with 6562 additions and 797 deletions
@@ -0,0 +1,121 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
[TestFixture]
public class ServiceApiKeyTests
{
private const string ConfiguredKey = "s3rvice-key-value";
private TestDatabase _db = null!;
[SetUp]
public void SetUp() => _db = new TestDatabase();
[TearDown]
public void TearDown() => _db.Dispose();
[Test]
public async Task A_request_with_the_configured_service_key_is_admitted_as_the_service_user()
{
await ServiceUser.EnsureSeededAsync(_db.Context, ConfiguredKey, NullLogger.Instance);
var result = await AuthenticateAsync(ConfiguredKey, ConfiguredKey);
Assert.Multiple(() =>
{
Assert.That(result.Succeeded, Is.True);
Assert.That(result.Principal?.FindFirstValue(ClaimTypes.NameIdentifier), Is.EqualTo(ServiceUser.Id.ToString()));
Assert.That(result.Principal?.FindFirstValue(ClaimTypes.Role), Is.EqualTo(nameof(GlobalRole.Admin)));
});
}
[Test]
public async Task A_request_with_a_wrong_key_is_rejected()
{
await ServiceUser.EnsureSeededAsync(_db.Context, ConfiguredKey, NullLogger.Instance);
var result = await AuthenticateAsync(ConfiguredKey, "not-the-key");
Assert.Multiple(() =>
{
Assert.That(result.Succeeded, Is.False);
Assert.That(result.Principal, Is.Null);
});
}
[Test]
public async Task The_api_still_serves_signed_in_users_when_no_service_key_is_configured()
{
var seeded = await ServiceUser.EnsureSeededAsync(_db.Context, null, NullLogger.Instance);
var signedIn = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())], "Identity.Application"));
var context = new DefaultHttpContext { User = signedIn };
var result = await AuthenticateAsync(configuredKey: null, presentedKey: null, context);
Assert.Multiple(() =>
{
Assert.That(seeded, Is.Null);
Assert.That(result.None, Is.True);
Assert.That(context.User, Is.SameAs(signedIn));
});
}
[Test]
public async Task A_service_key_presented_when_none_is_configured_is_rejected()
{
var result = await AuthenticateAsync(configuredKey: null, presentedKey: "anything");
Assert.That(result.Succeeded, Is.False);
}
[Test]
public async Task Seeding_the_service_user_twice_leaves_one_row()
{
await ServiceUser.EnsureSeededAsync(_db.Context, ConfiguredKey, NullLogger.Instance);
await ServiceUser.EnsureSeededAsync(_db.Context, ConfiguredKey, NullLogger.Instance);
Assert.That(_db.Context.Users.Count(u => u.Id == ServiceUser.Id), Is.EqualTo(1));
}
private async Task<AuthenticateResult> AuthenticateAsync(string? configuredKey, string? presentedKey, DefaultHttpContext? context = null)
{
var settings = configuredKey is null
? new Dictionary<string, string?>()
: new Dictionary<string, string?> { [ServiceApiKeyAuthenticationHandler.ConfigurationKey] = configuredKey };
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var handler = new ServiceApiKeyAuthenticationHandler(
new StaticSchemeOptions(), NullLoggerFactory.Instance, UrlEncoder.Default, configuration, _db.Context);
var httpContext = context ?? new DefaultHttpContext();
if (presentedKey is not null)
{
httpContext.Request.Headers[ServiceApiKeyAuthenticationHandler.HeaderName] = presentedKey;
}
var scheme = new AuthenticationScheme(
ServiceApiKeyAuthenticationHandler.SchemeName, null, typeof(ServiceApiKeyAuthenticationHandler));
await handler.InitializeAsync(scheme, httpContext);
return await handler.AuthenticateAsync();
}
private class StaticSchemeOptions : IOptionsMonitor<AuthenticationSchemeOptions>
{
public AuthenticationSchemeOptions CurrentValue { get; } = new();
public AuthenticationSchemeOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<AuthenticationSchemeOptions, string?> listener) => null;
}
}