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
35 changes: 31 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,37 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Build API starter
run: dotnet build backend/Platform.Api/Platform.Api.csproj --configuration Release
- name: Test
run: dotnet test backend/Platform.Tests/Platform.Tests.csproj --configuration Release
- name: Build backend solution
run: dotnet build backend/SecurityLab.slnx --configuration Release
- name: Test backend solution
run: >
dotnet test backend/SecurityLab.slnx --configuration Release --no-build
--logger "trx" --results-directory TestResults
- name: Verify tests were discovered and executed
shell: bash
run: |
mapfile -t files < <(find TestResults -name '*.trx' 2>/dev/null)
if [ "${#files[@]}" -eq 0 ]; then
echo "::error::No TRX result files were produced."
exit 1
fi
total=0
for f in "${files[@]}"; do
n=$(grep -o 'executed="[0-9]*"' "$f" | head -n 1 | grep -o '[0-9]*' || true)
total=$((total + ${n:-0}))
done
echo "Executed tests: $total"
if [ "$total" -eq 0 ]; then
echo "::error::Zero tests were executed; a green run with no tests proves nothing."
exit 1
fi
- name: Upload backend test results
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-test-results-${{ github.sha }}
path: TestResults/
if-no-files-found: error
frontend:
runs-on: ubuntu-latest
steps:
Expand Down
1 change: 1 addition & 0 deletions backend/Platform.Api/Platform.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Platform.Access\Platform.Access.csproj" />
<ProjectReference Include="..\Platform.Audit\Platform.Audit.csproj" />
</ItemGroup>
</Project>
2 changes: 2 additions & 0 deletions backend/Platform.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Platform.Api.Domain.Identity;
using Platform.Api.Middlewares;
using Platform.Api.Services.Identity;
using Platform.Audit.Composition;

// Oba modula imaju interfejs IAccessDecision, pa se koriste aliasi da ime ne bude dvosmisleno.
using IdentityAccessDecision = Platform.Api.Services.Identity.IAccessDecision;
Expand All @@ -13,6 +14,7 @@
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAccessControl();
builder.Services.AddAudit();
builder.Services.AddControllers();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(TimeProvider.System);
Expand Down
48 changes: 48 additions & 0 deletions backend/Platform.Audit.Tests/CompositionScopeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Platform.Access.Composition;
using Platform.Access.Contracts;
using Platform.Audit.Composition;

namespace Platform.Audit.Tests;

// Module registrations must survive strict DI validation (composition level).
[TestFixture]
public sealed class CompositionScopeTests
{
private static readonly ServiceProviderOptions StrictOptions = new()
{
ValidateScopes = true,
ValidateOnBuild = true,
};

[Test]
public void AccessAndAuditRegistrations_PassScopeAndBuildValidation()
{
var services = new ServiceCollection();
services.AddAccessControl();
services.AddAudit();

using var provider = services.BuildServiceProvider(StrictOptions);
using var scope = provider.CreateScope();

var decision = scope.ServiceProvider.GetRequiredService<IAccessDecision>();

Assert.That(decision, Is.Not.Null);
}

[Test]
public void CaptiveDependency_IsRejectedByValidation()
{
var services = new ServiceCollection();
services.AddScoped<ScopedDependency>();
services.AddSingleton<SingletonConsumer>();

var exception = Assert.Catch<Exception>(() =>
{
services.BuildServiceProvider(StrictOptions);
});

Assert.That(exception?.ToString(), Does.Contain("scoped").IgnoreCase);
}
}
56 changes: 56 additions & 0 deletions backend/Platform.Audit.Tests/HostStartupScopeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NUnit.Framework;

namespace Platform.Audit.Tests;

// The real Platform.Api host must start with strict DI validation (host level).
// The Development environment enables ValidateScopes and ValidateOnBuild by default.
internal sealed class StrictHostFactory(bool addCaptiveDependency = false)
: WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureLogging(logging => logging.ClearProviders());

if (addCaptiveDependency)
{
builder.ConfigureServices(services =>
{
services.AddScoped<ScopedDependency>();
services.AddSingleton<SingletonConsumer>();
});
}
}
}

[TestFixture]
public sealed class HostStartupScopeTests
{
[Test]
public async Task Host_StartsWithScopeValidation_AndHealthResponds()
{
using var factory = new StrictHostFactory();
using var client = factory.CreateClient();

using var response = await client.GetAsync("/api/health");

Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}

[Test]
public void Host_WithCaptiveDependency_FailsToStart()
{
var exception = Assert.Catch<Exception>(() =>
{
using var factory = new StrictHostFactory(addCaptiveDependency: true);
using var client = factory.CreateClient();
});

Assert.That(exception?.ToString(), Does.Contain("scoped").IgnoreCase);
}
}
20 changes: 20 additions & 0 deletions backend/Platform.Audit.Tests/Platform.Audit.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Platform.Access/Platform.Access.csproj" />
<ProjectReference Include="../Platform.Api/Platform.Api.csproj" />
<ProjectReference Include="../Platform.Audit/Platform.Audit.csproj" />
</ItemGroup>
</Project>
12 changes: 12 additions & 0 deletions backend/Platform.Audit.Tests/ScopeValidationFixtures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Platform.Audit.Tests;

// Deliberate misconfiguration used to prove that scope validation is really
// switched on: a singleton that captures a scoped service (a "captive dependency").
internal sealed class ScopedDependency
{
}

internal sealed class SingletonConsumer(ScopedDependency dependency)
{
public ScopedDependency Dependency { get; } = dependency;
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;

namespace Platform.Audit.Composition;

public static class AuditServiceCollectionExtensions
{
/// <summary>
/// Registers the Audit module with the host. This is the single entry point
/// that Platform.Api calls; the host never registers Audit internals directly.
/// </summary>
/// <remarks>
/// Skeleton only: nothing is registered yet. The audit contract, the writer and
/// the storage add their registrations here. Lifetimes follow
/// docs/components/audit/DI-GUIDELINES.md and are verified by the
/// scope-validation tests in Platform.Audit.Tests.
/// </remarks>
public static IServiceCollection AddAudit(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);

return services;
}
}
Empty file.
Empty file.
Empty file.
10 changes: 10 additions & 0 deletions backend/Platform.Audit/Platform.Audit.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
7 changes: 7 additions & 0 deletions backend/SecurityLab.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Solution>
<Project Path="Platform.Access/Platform.Access.csproj" />
<Project Path="Platform.Api/Platform.Api.csproj" />
<Project Path="Platform.Audit/Platform.Audit.csproj" />
<Project Path="Platform.Audit.Tests/Platform.Audit.Tests.csproj" />
<Project Path="Platform.Tests/Platform.Tests.csproj" />
</Solution>
74 changes: 74 additions & 0 deletions docs/components/audit/DI-GUIDELINES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Audit module: dependency injection lifetimes

**Status:** proposal by the Audit component (AUD-01, AUD-06). It becomes a rule only after
it is agreed with the owners of `Platform.Access` and the Identity/Membership modules.
Nothing here is claimed as already accepted by them.

## Why this matters

Audit and authorization run on every request and will soon touch scoped services
(`DbContext`, the current identity, course membership). A singleton that captures a scoped
service reads one request's state on behalf of another request. In an authorization or
audit path that is a security defect, not only a bug.

## Lifetime rules

| Kind of service | Lifetime | Notes |
| --- | --- | --- |
| Read-only catalogs and registries, filled once during composition | Singleton | No per-request state. Safe for concurrent use. Registration happens before the first request; a duplicate fails at startup. |
| Anything that uses `DbContext`, the current identity, membership or a per-request policy | Scoped | Includes the audit writer once it persists to PostgreSQL. |
| Stateless helpers and validators | Singleton or transient | Only if they hold no request data. |
| Background work (outbox worker, delegation expiry) | Own scope per unit of work | Create it with `IServiceScopeFactory`; never use the root provider for scoped services. |

Forbidden:

- Injecting a scoped service into a singleton (captive dependency).
- Resolving a scoped service from the root provider.
- Relying on "last registration wins" to replace an adapter. Replace it explicitly.

## Current state (checked on `main`, commit `e7474c7`)

- `AddAccessControl()` registers `IAccessDecision` and `IAccessAuditWriter` as singletons.
This is valid today because their dependencies are singletons too.
- It stops being valid when a real audit adapter backed by a scoped `DbContext` replaces
`NullAccessAuditWriter`. `IAccessDecision` and `IAccessAuditWriter` must then become
scoped together, otherwise the host will not start under validation. This change belongs
to the Access integration and needs review by the Access team.
- `Platform.Api` now also registers the Identity services. Identity has its own `IAuditWriter`
(a singleton `LoggerAuditWriter`) and its own `IAccessDecision`, both in the
`Platform.Api.Services.Identity` namespace. They are separate from the Access contracts and
from the future Audit contract. Which audit contract the Identity code should use is an open
question for the Audit and Identity owners.
- `AddAudit()` registers nothing yet. It is the only entry point `Platform.Api` calls for the
Audit module.

## Proposed registration order

`AddAccessControl()` first, then `AddAudit()`. A later adapter that replaces
`NullAccessAuditWriter` must do so with an explicit, documented registration, not by
accident of ordering.

## How this is verified

`backend/Platform.Audit.Tests` runs with `ValidateScopes` and `ValidateOnBuild` enabled:

- `CompositionScopeTests` builds the Access and Audit registrations and resolves
`IAccessDecision` inside a scope.
- `HostStartupScopeTests` starts the real `Platform.Api` host in the Development
environment (strict validation is the default there) and calls `/api/health`.
- Each level has a negative test with a deliberate captive dependency, which proves the
validation is actually active. Without it a passing test would prove nothing.

Run from the repository root:

```bash
dotnet test backend/SecurityLab.slnx --configuration Release
```

## Open questions

- Should the audit writer be scoped, or a singleton that opens its own short-lived
`DbContext` per call through `IDbContextFactory`? Decide when the storage and the
writer are designed.
- Who owns the final registration order in `Program.cs`? Proposal: the Audit component
owner, with review by the Access team for any change to Access composition.
Loading