From 0a22f012a8bb523f51761aef633f1502d39ac8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Jevti=C4=87?= <245982936+jevt1c@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:20:35 +0100 Subject: [PATCH 1/4] AUD-01: add draft audit writer contract (S-02) --- docs/components/audit/CONTRACTS.md | 275 +++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/components/audit/CONTRACTS.md diff --git a/docs/components/audit/CONTRACTS.md b/docs/components/audit/CONTRACTS.md new file mode 100644 index 0000000..9d1384e --- /dev/null +++ b/docs/components/audit/CONTRACTS.md @@ -0,0 +1,275 @@ +# Contract: `IAuditWriter` (Audit module) + +| | | +|-|-| +| **Owner** | Team 12, Audit component (AUD-01, AUD-06) | +| **Version** | 0.1 (draft, not yet accepted by the consumers listed below) | +| **Spec reference** | Specification 3.1: structured security record, without personal secrets or sensitive solutions | +| **Requirements** | AUD-01, AUD-03, AUD-04, SEC-02, OPS-05 | + +Until a consumer confirms it in a reviewed pull request, every name, field and limit below is a +proposal. Nothing here is claimed as an existing or accepted API. + +## Purpose + +Any module that performs a security-relevant action (login, change of rights, denied access) +reports it through one contract. The Audit module validates the report, adds the trusted +context (who, when, which request) and stores it as an append-only record. AUD-06 later reads +those records for teachers, filtered by course on the server. + +## What this contract is not + +- **Not a technical log.** `ILogger` output is for diagnostics. An audit record is a security + record with an owner, a schema and access control. +- **Not the attempt action log.** `AttemptAction` (ATT-08) belongs to the Attempts module. +- **Not laboratory data.** Scenarios 5.2 and 5.7 use synthetic events, never the platform audit. + +## Visibility and dependency direction + +- Only the types in the `Platform.Audit.Contracts` namespace and `AddAudit()` are public. + Domain, application and infrastructure types of the module are `internal`. +- `Platform.Access` does **not** reference `Platform.Audit`. The host (`Platform.Api`) connects + them with an adapter that implements `IAccessAuditWriter` and calls this contract (story S-13). +- Producers never read or write the audit table directly. + +## Members + +### `IAuditWriter` + +```csharp +public interface IAuditWriter +{ + ValueTask WriteAsync( + AuditEventDraft draft, + CancellationToken cancellationToken = default); +} +``` + +The usual entry point. The caller describes **what** happened. The writer takes the actor and +the correlation ID from the trusted server context of the current request, and the time from +`TimeProvider` (UTC). The caller cannot set the actor, the time or the correlation ID. + +### `ITrustedAuditWriter` + +```csharp +public interface ITrustedAuditWriter +{ + ValueTask WriteAsync( + AuditEventDraft draft, + AuditContext context, + CancellationToken cancellationToken = default); +} +``` + +For server-side adapters only, when the actor is already confirmed but is not yet (or no longer) +in the request context: + +- successful login: credentials were verified, the session is not issued yet; +- the Access adapter: it forwards the subject of an access decision that was already evaluated; +- background jobs, such as expiry of a delegation: the actor is a service identity. + +`AuditContext` must never be built from an HTTP body, query string or arbitrary header. The +compiler cannot enforce this; it is checked in code review (see residual risk below). + +### `AuditEventDraft` + +| Field | Type | Rule | +|-|-|-| +| `EventType` | `string` | Required. Must be a type from the catalog below. An unknown type is rejected. | +| `Outcome` | `AuditOutcome` | Required. Must be allowed for that event type. | +| `CourseId` | `Guid?` | Required, forbidden or optional depending on the event type. Comes from a trusted business context, never from client input. | +| `Resource` | `AuditResourceRef?` | The subject of the change (for example the account whose rights changed). It is not the actor. | +| `Details` | `IReadOnlyDictionary?` | Only keys allowed for that event type, with string values. No nested objects. | +| `EventId` | `Guid?` | Optional idempotency key. Required for producers that retry or use an outbox. When `null`, the writer generates one. | + +### `AuditOutcome` + +| Value | Meaning | +|-|-| +| `Success` | The action was carried out and persisted. | +| `Denied` | The action was refused by an authorization or policy check. | +| `Failure` | The action was allowed but failed (invalid input, missing target, technical error). | + +A technical failure is never recorded as `Success`. + +### `AuditResourceRef` + +`AuditResourceRef(string Type, string Id)`. `Type` is one of `Account`, `Course`, `Delegation`. +`Id` has at most 64 characters: ASCII letters, digits, `-` and `_`. + +### `AuditContext` and `AuditActor` + +```csharp +public sealed record AuditContext(AuditActor Actor, string CorrelationId); +public sealed record AuditActor(AuditActorKind Kind, Guid? Id); +public enum AuditActorKind { User, Service, Anonymous } +``` + +| Kind | `Id` | Used for | +|-|-|-| +| `User` | required | an authenticated person | +| `Service` | required | a registered service identity (background job) | +| `Anonymous` | must be `null` | a caller without a confirmed identity, for example a failed login | + +The email address or user name typed into a login form is **not** an actor and is never stored. +Users sometimes type their password into that field. + +`CorrelationId` has 1 to 64 characters: ASCII letters, digits, `-`, `_` and `.`. One request can +produce several events with the same correlation ID, so it is not a deduplication key. + +### `AuditWriteResult` + +```csharp +public sealed record AuditWriteResult( + AuditWriteStatus Status, + AuditRejectionCode RejectionCode, + Guid? EventId); + +public enum AuditWriteStatus { Recorded, Rejected, Unavailable } +``` + +| Status | Meaning | Producer must | +|-|-|-| +| `Recorded` | The event is stored. `EventId` identifies it. | continue | +| `Rejected` | The draft breaks the contract. Nothing is stored. `RejectionCode` says why. | treat it as a defect in its own code; do not confirm a privileged change | +| `Unavailable` | Storage failed. Nothing is guaranteed to be stored. | treat it as failure (see producer obligations) | + +`AuditRejectionCode` values: `None`, `UnknownEventType`, `OutcomeNotAllowed`, `CourseRequired`, +`CourseNotAllowed`, `InvalidResource`, `InvalidContext`, `UnknownDetailKey`, `ForbiddenDetailKey`, +`InvalidDetailValue`, `DetailsTooLarge`, `EventIdConflict`. + +The writer does not throw for expected failures. The only exception it lets through is +`OperationCanceledException` when the caller's token was cancelled before the event was stored. + +**Repeated `EventId`:** the same `EventId` with the same content returns `Recorded` with that +`EventId` and stores nothing new. The same `EventId` with different content returns `Rejected` +with `EventIdConflict` and never replaces the stored record. + +## Producer obligations + +1. **Privileged change that was allowed** (status change, role change, delegation): the business + change may be confirmed only when its audit record is guaranteed. Either both are committed in + one transaction, or the change and an outbox entry are committed together. Which of the two is + decided in D3 (stories S-12 and S-16). Returning an error after the change is already committed + is not fail-closed. +2. **Denied action:** the denial stays a denial even if the audit write fails. The producer raises + a technical alarm without secrets. An audit failure never grants access. +3. **Audit of an action that is already committed:** pass `CancellationToken.None` (or hand the + event to the outbox), so that a cancelled HTTP request does not lose the record. +4. **Never put into a draft:** passwords (even wrong ones), tokens, cookies, session identifiers, + `Authorization` headers, evaluation keys, student answers, request or response bodies, typed + login names or any free text. + +## Event catalog (schema version 1) + +| Event type | Producer (requirement) | Outcomes | Actor | Course | Resource | Allowed details | +|-|-|-|-|-|-|-| +| `auth.login` | Identity (IDN-02) | Success, Failure | Success: `User`. Failure: `Anonymous`, or `User` when the credentials were valid but the account is not active | forbidden | `Account` on Success | `reasonCode` | +| `auth.logout` | Identity (IDN-02) | Success | `User` | forbidden | none | none | +| `auth.login.throttled` | Identity (IDN-05) | Denied | `Anonymous` | forbidden | none | none | +| `account.status.changed` | Identity (IDN-03), Admin (ADM-01) | Success, Denied, Failure | `User` or `Anonymous` (Denied, not logged in) | forbidden | `Account` (required) | `oldStatus`, `newStatus`, `reasonCode` | +| `course.member.role.changed` | Memberships (ACC-03) | Success, Denied, Failure | `User` or `Anonymous` (Denied) | required | `Account` (required) | `oldRole`, `newRole`, `reasonCode` | +| `course.delegation.granted` | Memberships (ACC-07) | Success, Denied, Failure | `User` or `Anonymous` (Denied) | required | `Account` (required) | `action`, `delegationId`, `reasonCode` | +| `course.delegation.revoked` | Memberships (ACC-07) | Success, Denied, Failure | `User` or `Anonymous` (Denied) | required | `Account` (required) | `action`, `delegationId`, `reasonCode` | +| `course.delegation.expired` | Memberships (ACC-07), background job | Success | `Service` | required | `Account` (required) | `action`, `delegationId` | +| `access.denied` | Access, Team 3 (ACC-05), through the host adapter | Denied | `User` or `Anonymous` | optional, only from a trusted resource resolver | optional | `action`, `denialReason`, `policyId`, `policyVersion` | +| `audit.events.viewed` | Audit (AUD-06) | Success | `User` | required | `Course` (required) | `filterType`, `filterOutcome`, `filterFrom`, `filterTo`, `resultCount` | + +`reasonCode` is only allowed when the outcome is `Denied` or `Failure`. + +### Detail value formats + +| Key | Format | +|-|-| +| `reasonCode` | code: `^[a-z][a-z0-9_]{0,31}$`, for example `invalid_credentials`, `account_blocked`, `forbidden`, `not_found` | +| `oldStatus`, `newStatus` | one of `active`, `blocked`, `archived` | +| `oldRole`, `newRole` | one of `none`, `student`, `teacher`, `assistant` | +| `action`, `filterType` | action or event type: `^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$`, at most 64 characters (same rule as `ActionId` in `Platform.Access`) | +| `denialReason` | one of `unauthenticated`, `account_not_active`, `unknown_action`, `no_policy`, `policy_error` | +| `policyId` | code, at most 64 characters | +| `policyVersion` | `^v[0-9]{1,4}(\.[0-9]{1,4}){0,2}$` | +| `delegationId` | UUID | +| `filterOutcome` | one of `success`, `denied`, `failure` | +| `filterFrom`, `filterTo` | UTC timestamp, ISO 8601, ending with `Z` | +| `resultCount` | integer from 0 to 100000 | + +## Limits + +| Item | Limit | +|-|-| +| `EventType` | at most 64 characters, same pattern as `action` | +| Detail keys per event | at most 20 | +| Detail key | `^[a-z][a-zA-Z0-9]{0,31}$`, and it must be allowed for the event type | +| Detail value | its format above; at most 64 characters; no control characters | +| All details together | at most 2048 bytes after serialization to UTF-8 JSON | + +The allowlist is the primary control. As an additional check, a key whose name contains +`password`, `passwd`, `secret`, `token`, `authorization`, `apikey`, `cookie` or `session` +(ignoring case) is rejected with `ForbiddenDetailKey` even if someone adds it to the catalog by +mistake. A rejected value is never copied into a log, exception message or HTTP response. + +## Versioning + +- The contract has a version (now `0.1`). Every stored event records the schema version of its + type (now `1`). +- Adding a new event type needs a catalog entry, its detail schema and a test. It does not break + existing producers. +- Removing or renaming a field, an enum value or an event type, or changing what an outcome + means, is a breaking change even if no method signature changes. It needs a migration plan + (OPS-05). + +## Consumers + +| Team / module | Events | Current state | +|-|-|-| +| Identity, Teams 1 and 2 (IDN-02, IDN-03, IDN-05) | `auth.*`, `account.status.changed` | Has its own `IAuditWriter` in `Platform.Api.Services.Identity`, which writes to `ILogger`. Needs an agreed adapter or a switch to this contract. | +| Access, Team 3 (ACC-05, ACC-08) | `access.denied` | Calls `IAccessAuditWriter`; today `NullAccessAuditWriter`. Connected by the host adapter (S-13). | +| Memberships and delegation (ACC-03, ACC-07) | `course.*` | Not on `main` yet. | +| Revision publishing (AUD-02) | not in this version | Can add its event type through a catalog change. | +| Audit query (AUD-06) | `audit.events.viewed` | Our own story S-14/S-15. | + +Two interfaces named `IAuditWriter` exist in different namespaces. In `Program.cs` use an alias, +as is already done for the two `IAccessDecision` interfaces. + +## Test double + +`FakeAuditWriter` implements both writer interfaces, records every draft and returns a result +that the test sets (`Recorded`, `Rejected` with a code, or `Unavailable`). It does not validate +like the real writer, so a test that passes with the fake does not prove the contract rules. +Those are proven by the Audit module's own tests. + +## How the contract is verified (test group T03) + +Planned tests in `backend/Platform.Audit.Tests`, each with its negative case: + +- unknown event type: `Rejected`, `UnknownEventType`, nothing stored; +- outcome not allowed for the type: `OutcomeNotAllowed`; +- missing or forbidden `CourseId`: `CourseRequired` / `CourseNotAllowed`; +- unknown detail key, forbidden key name, invalid value, too many keys, more than 2048 bytes; +- actor rules: `User` or `Service` without `Id`, `Anonymous` with `Id`: `InvalidContext`; +- every catalog entry accepts a valid example, so the catalog and its tests stay in sync. + +## Residual risk + +- `ITrustedAuditWriter` is a public interface, so the compiler cannot stop a module from + passing a forged `AuditContext`. Mitigation: code review of every use, and only server + adapters registered in composition. +- Append-only rules in the application do not protect against a database administrator or a + fully compromised server process. Storage rights are handled in S-05/S-16. + +## Open questions + +| # | Question | Owner | +|-|-|-| +| 1 | Does Identity switch to this contract or keep its own interface behind an adapter? `ChangeAccountStatus` currently writes a free-text administrator reason into `Detail`, which conflicts with AUD-03; the proposal is a `reasonCode` instead. | Member 9 with Teams 1 and 2 (D1) | +| 2 | Shared transaction or transactional outbox for privileged changes. | Members 2, 3 and 9 with producers (D3) | +| 3 | Is `auth.login` with valid credentials on a blocked account recorded with a `User` actor? (Proposed: yes; the identity is confirmed, the login is refused.) | Members 3, 8 and 9 with Team 1 | +| 4 | Who sees events without a course (global or administrator view)? Proposed: nobody through the teacher API. | Members 5, 7 and 9 with the assistant (D4) | +| 5 | Is an incoming `X-Correlation-Id` header accepted, and in which format? | Member 10 with the AUD-04 owner | +| 6 | Retention, archive and anonymization rules. | Members 2, 7 and 9 with the ADM owners (D8) | + +## Change history + +| Version | Date | Change | +|-|-|-| +| 0.1 | 2026-11-03 | Initial draft for review (S-02) | From 81e13fc1714c466068c8d7daf773362e1f8da682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Jevti=C4=87?= <245982936+jevt1c@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:47:20 +0100 Subject: [PATCH 2/4] AUD-01: add audit writer contract types (S-02) --- .../Platform.Audit/Contracts/AuditActor.cs | 21 ++++++++ .../Platform.Audit/Contracts/AuditContext.cs | 5 ++ .../Contracts/AuditEventDraft.cs | 19 +++++++ .../Contracts/AuditEventTypes.cs | 21 ++++++++ .../Platform.Audit/Contracts/AuditOutcome.cs | 13 +++++ .../Contracts/AuditResourceRef.cs | 12 +++++ .../Contracts/AuditWriteResult.cs | 49 +++++++++++++++++++ .../Platform.Audit/Contracts/IAuditWriter.cs | 13 +++++ .../Contracts/ITrustedAuditWriter.cs | 14 ++++++ 9 files changed, 167 insertions(+) create mode 100644 backend/Platform.Audit/Contracts/AuditActor.cs create mode 100644 backend/Platform.Audit/Contracts/AuditContext.cs create mode 100644 backend/Platform.Audit/Contracts/AuditEventDraft.cs create mode 100644 backend/Platform.Audit/Contracts/AuditEventTypes.cs create mode 100644 backend/Platform.Audit/Contracts/AuditOutcome.cs create mode 100644 backend/Platform.Audit/Contracts/AuditResourceRef.cs create mode 100644 backend/Platform.Audit/Contracts/AuditWriteResult.cs create mode 100644 backend/Platform.Audit/Contracts/IAuditWriter.cs create mode 100644 backend/Platform.Audit/Contracts/ITrustedAuditWriter.cs diff --git a/backend/Platform.Audit/Contracts/AuditActor.cs b/backend/Platform.Audit/Contracts/AuditActor.cs new file mode 100644 index 0000000..60469b9 --- /dev/null +++ b/backend/Platform.Audit/Contracts/AuditActor.cs @@ -0,0 +1,21 @@ +namespace Platform.Audit.Contracts; + +/// +/// and require an Id; +/// must not have one. +/// +public sealed record AuditActor(AuditActorKind Kind, Guid? Id) +{ + public static AuditActor Anonymous { get; } = new(AuditActorKind.Anonymous, null); + + public static AuditActor ForUser(Guid userId) => new(AuditActorKind.User, userId); + + public static AuditActor ForService(Guid serviceId) => new(AuditActorKind.Service, serviceId); +} + +public enum AuditActorKind +{ + User, + Service, + Anonymous +} diff --git a/backend/Platform.Audit/Contracts/AuditContext.cs b/backend/Platform.Audit/Contracts/AuditContext.cs new file mode 100644 index 0000000..df6341b --- /dev/null +++ b/backend/Platform.Audit/Contracts/AuditContext.cs @@ -0,0 +1,5 @@ +namespace Platform.Audit.Contracts; + +/// Who performed the action, as confirmed by the server. +/// 1 to 64 characters: ASCII letters, digits, '-', '_' and '.'. +public sealed record AuditContext(AuditActor Actor, string CorrelationId); diff --git a/backend/Platform.Audit/Contracts/AuditEventDraft.cs b/backend/Platform.Audit/Contracts/AuditEventDraft.cs new file mode 100644 index 0000000..99d25cb --- /dev/null +++ b/backend/Platform.Audit/Contracts/AuditEventDraft.cs @@ -0,0 +1,19 @@ +namespace Platform.Audit.Contracts; + +/// +/// What happened. It deliberately has no actor, time or correlation ID: the caller +/// cannot set them. +/// +/// A type from . +/// Must be allowed for the event type. +/// Required, forbidden or optional depending on the event type. +/// The subject of the change; not the actor. +/// Only keys allowed for the event type. +/// Idempotency key for producers that retry; generated when null. +public sealed record AuditEventDraft( + string EventType, + AuditOutcome Outcome, + Guid? CourseId = null, + AuditResourceRef? Resource = null, + IReadOnlyDictionary? Details = null, + Guid? EventId = null); diff --git a/backend/Platform.Audit/Contracts/AuditEventTypes.cs b/backend/Platform.Audit/Contracts/AuditEventTypes.cs new file mode 100644 index 0000000..b3b133f --- /dev/null +++ b/backend/Platform.Audit/Contracts/AuditEventTypes.cs @@ -0,0 +1,21 @@ +namespace Platform.Audit.Contracts; + +/// +/// Event types of catalog schema version 1. Adding a type also needs its schema +/// and a test; see docs/components/audit/CONTRACTS.md. +/// +public static class AuditEventTypes +{ + public const string ContractVersion = "0.1"; + + public const string AuthLogin = "auth.login"; + public const string AuthLogout = "auth.logout"; + public const string AuthLoginThrottled = "auth.login.throttled"; + public const string AccountStatusChanged = "account.status.changed"; + public const string CourseMemberRoleChanged = "course.member.role.changed"; + public const string CourseDelegationGranted = "course.delegation.granted"; + public const string CourseDelegationRevoked = "course.delegation.revoked"; + public const string CourseDelegationExpired = "course.delegation.expired"; + public const string AccessDenied = "access.denied"; + public const string AuditEventsViewed = "audit.events.viewed"; +} diff --git a/backend/Platform.Audit/Contracts/AuditOutcome.cs b/backend/Platform.Audit/Contracts/AuditOutcome.cs new file mode 100644 index 0000000..8c90d1c --- /dev/null +++ b/backend/Platform.Audit/Contracts/AuditOutcome.cs @@ -0,0 +1,13 @@ +namespace Platform.Audit.Contracts; + +public enum AuditOutcome +{ + /// The action was carried out and persisted. + Success, + + /// The action was refused by an authorization or policy check. + Denied, + + /// The action was allowed but failed. + Failure +} diff --git a/backend/Platform.Audit/Contracts/AuditResourceRef.cs b/backend/Platform.Audit/Contracts/AuditResourceRef.cs new file mode 100644 index 0000000..24fba69 --- /dev/null +++ b/backend/Platform.Audit/Contracts/AuditResourceRef.cs @@ -0,0 +1,12 @@ +namespace Platform.Audit.Contracts; + +/// One of . +/// At most 64 characters: ASCII letters, digits, '-' and '_'. +public sealed record AuditResourceRef(string Type, string Id); + +public static class AuditResourceTypes +{ + public const string Account = "Account"; + public const string Course = "Course"; + public const string Delegation = "Delegation"; +} diff --git a/backend/Platform.Audit/Contracts/AuditWriteResult.cs b/backend/Platform.Audit/Contracts/AuditWriteResult.cs new file mode 100644 index 0000000..70b89c6 --- /dev/null +++ b/backend/Platform.Audit/Contracts/AuditWriteResult.cs @@ -0,0 +1,49 @@ +namespace Platform.Audit.Contracts; + +/// +/// Anything other than is a failure for the caller. +/// +public sealed record AuditWriteResult( + AuditWriteStatus Status, + AuditRejectionCode RejectionCode, + Guid? EventId) +{ + public bool IsRecorded => Status == AuditWriteStatus.Recorded; + + public static AuditWriteResult Recorded(Guid eventId) => + new(AuditWriteStatus.Recorded, AuditRejectionCode.None, eventId); + + public static AuditWriteResult Rejected(AuditRejectionCode code) => + new(AuditWriteStatus.Rejected, code, null); + + public static AuditWriteResult Unavailable() => + new(AuditWriteStatus.Unavailable, AuditRejectionCode.None, null); +} + +public enum AuditWriteStatus +{ + /// The event is stored. + Recorded, + + /// The draft breaks the contract; nothing is stored. + Rejected, + + /// Storage failed; nothing is guaranteed to be stored. + Unavailable +} + +public enum AuditRejectionCode +{ + None, + UnknownEventType, + OutcomeNotAllowed, + CourseRequired, + CourseNotAllowed, + InvalidResource, + InvalidContext, + UnknownDetailKey, + ForbiddenDetailKey, + InvalidDetailValue, + DetailsTooLarge, + EventIdConflict +} diff --git a/backend/Platform.Audit/Contracts/IAuditWriter.cs b/backend/Platform.Audit/Contracts/IAuditWriter.cs new file mode 100644 index 0000000..130dc71 --- /dev/null +++ b/backend/Platform.Audit/Contracts/IAuditWriter.cs @@ -0,0 +1,13 @@ +namespace Platform.Audit.Contracts; + +/// +/// Records a security-relevant event (AUD-01). The caller describes what happened; +/// the actor, the time and the correlation ID come from the trusted server context. +/// See docs/components/audit/CONTRACTS.md. +/// +public interface IAuditWriter +{ + ValueTask WriteAsync( + AuditEventDraft draft, + CancellationToken cancellationToken = default); +} diff --git a/backend/Platform.Audit/Contracts/ITrustedAuditWriter.cs b/backend/Platform.Audit/Contracts/ITrustedAuditWriter.cs new file mode 100644 index 0000000..5acdeca --- /dev/null +++ b/backend/Platform.Audit/Contracts/ITrustedAuditWriter.cs @@ -0,0 +1,14 @@ +namespace Platform.Audit.Contracts; + +/// +/// Records an event with an already confirmed context. For server-side adapters only +/// (successful login, the Access adapter, background jobs). The context must never be +/// built from an HTTP body, query string or header. +/// +public interface ITrustedAuditWriter +{ + ValueTask WriteAsync( + AuditEventDraft draft, + AuditContext context, + CancellationToken cancellationToken = default); +} From abd1a6961c6276ae233c3e713eb0c873bb27033c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Jevti=C4=87?= <245982936+jevt1c@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:00:43 +0100 Subject: [PATCH 3/4] AUD-01: add event catalog, draft validator and T03 tests (S-02) --- .../AuditDraftExamples.cs | 100 ++++++ .../AuditDraftValidatorTests.cs | 293 ++++++++++++++++++ .../AuditEventCatalogTests.cs | 61 ++++ .../Application/AuditDetailFormats.cs | 68 ++++ .../Application/AuditDraftValidator.cs | 120 +++++++ .../Contracts/AuditEventCatalog.cs | 147 +++++++++ .../Contracts/AuditEventSchema.cs | 62 ++++ backend/Platform.Audit/Platform.Audit.csproj | 3 + docs/components/audit/CONTRACTS.md | 15 +- 9 files changed, 866 insertions(+), 3 deletions(-) create mode 100644 backend/Platform.Audit.Tests/AuditDraftExamples.cs create mode 100644 backend/Platform.Audit.Tests/AuditDraftValidatorTests.cs create mode 100644 backend/Platform.Audit.Tests/AuditEventCatalogTests.cs create mode 100644 backend/Platform.Audit/Application/AuditDetailFormats.cs create mode 100644 backend/Platform.Audit/Application/AuditDraftValidator.cs create mode 100644 backend/Platform.Audit/Contracts/AuditEventCatalog.cs create mode 100644 backend/Platform.Audit/Contracts/AuditEventSchema.cs diff --git a/backend/Platform.Audit.Tests/AuditDraftExamples.cs b/backend/Platform.Audit.Tests/AuditDraftExamples.cs new file mode 100644 index 0000000..d20d80a --- /dev/null +++ b/backend/Platform.Audit.Tests/AuditDraftExamples.cs @@ -0,0 +1,100 @@ +using Platform.Audit.Contracts; + +namespace Platform.Audit.Tests; + +// One valid draft per catalog event type, with a matching trusted context. +// Synthetic identifiers only; no real accounts or courses. +internal static class AuditDraftExamples +{ + public static readonly Guid UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + public static readonly Guid TargetAccountId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + public static readonly Guid ServiceId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + public static readonly Guid CourseId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + public static readonly Guid DelegationId = Guid.Parse("55555555-5555-5555-5555-555555555555"); + + public const string CorrelationId = "corr-0001"; + + public static AuditContext UserContext { get; } = new(AuditActor.ForUser(UserId), CorrelationId); + public static AuditContext ServiceContext { get; } = new(AuditActor.ForService(ServiceId), CorrelationId); + public static AuditContext AnonymousContext { get; } = new(AuditActor.Anonymous, CorrelationId); + + public static AuditResourceRef TargetAccount { get; } = + new(AuditResourceTypes.Account, TargetAccountId.ToString()); + + public static AuditResourceRef Course { get; } = + new(AuditResourceTypes.Course, CourseId.ToString()); + + public static IEnumerable<(AuditEventDraft Draft, AuditContext Context)> ValidPerEventType() + { + yield return ( + new AuditEventDraft(AuditEventTypes.AuthLogin, AuditOutcome.Success, Resource: TargetAccount), + UserContext); + + yield return ( + new AuditEventDraft(AuditEventTypes.AuthLogout, AuditOutcome.Success), + UserContext); + + yield return ( + new AuditEventDraft(AuditEventTypes.AuthLoginThrottled, AuditOutcome.Denied), + AnonymousContext); + + yield return (StatusChange(), UserContext); + + yield return ( + new AuditEventDraft( + AuditEventTypes.CourseMemberRoleChanged, + AuditOutcome.Success, + CourseId, + TargetAccount, + Details(("oldRole", "student"), ("newRole", "assistant"))), + UserContext); + + yield return (Delegation(AuditEventTypes.CourseDelegationGranted), UserContext); + yield return (Delegation(AuditEventTypes.CourseDelegationRevoked), UserContext); + yield return (Delegation(AuditEventTypes.CourseDelegationExpired), ServiceContext); + + yield return ( + new AuditEventDraft( + AuditEventTypes.AccessDenied, + AuditOutcome.Denied, + Details: Details( + ("action", "audit.events.read"), + ("denialReason", "no_policy"), + ("policyVersion", "v0"))), + UserContext); + + yield return ( + new AuditEventDraft( + AuditEventTypes.AuditEventsViewed, + AuditOutcome.Success, + CourseId, + Course, + Details( + ("filterType", "auth.login"), + ("filterOutcome", "denied"), + ("filterFrom", "2026-11-01T00:00:00Z"), + ("filterTo", "2026-11-08T00:00:00Z"), + ("resultCount", "3"))), + UserContext); + } + + public static AuditEventDraft StatusChange( + AuditOutcome outcome = AuditOutcome.Success, + IReadOnlyDictionary? details = null) => + new( + AuditEventTypes.AccountStatusChanged, + outcome, + Resource: TargetAccount, + Details: details ?? Details(("oldStatus", "active"), ("newStatus", "blocked"))); + + public static IReadOnlyDictionary Details(params (string Key, string Value)[] entries) => + entries.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.Ordinal); + + private static AuditEventDraft Delegation(string eventType) => + new( + eventType, + AuditOutcome.Success, + CourseId, + TargetAccount, + Details(("action", "audit.events.read"), ("delegationId", DelegationId.ToString()))); +} diff --git a/backend/Platform.Audit.Tests/AuditDraftValidatorTests.cs b/backend/Platform.Audit.Tests/AuditDraftValidatorTests.cs new file mode 100644 index 0000000..7e3deaf --- /dev/null +++ b/backend/Platform.Audit.Tests/AuditDraftValidatorTests.cs @@ -0,0 +1,293 @@ +using NUnit.Framework; +using Platform.Audit.Application; +using Platform.Audit.Contracts; +using static Platform.Audit.Tests.AuditDraftExamples; + +namespace Platform.Audit.Tests; + +// T03 (AUD-01): a draft outside the catalog is rejected with a stable code. +[TestFixture] +public sealed class AuditDraftValidatorTests +{ + private readonly AuditDraftValidator _validator = new(); + + private static IEnumerable ValidExamples() => + AuditDraftExamples.ValidPerEventType() + .Select(example => new TestCaseData(example.Draft, example.Context) + .SetArgDisplayNames(example.Draft.EventType)); + + [TestCaseSource(nameof(ValidExamples))] + public void Validate_WhenDraftMatchesCatalog_AcceptsIt(AuditEventDraft draft, AuditContext context) + { + Assert.That(_validator.Validate(draft, context), Is.EqualTo(AuditRejectionCode.None)); + } + + [Test] + public void Validate_WhenFailedLoginIsAnonymousWithReason_AcceptsIt() + { + var draft = new AuditEventDraft( + AuditEventTypes.AuthLogin, + AuditOutcome.Failure, + Details: Details(("reasonCode", "invalid_credentials"))); + + Assert.That(_validator.Validate(draft, AnonymousContext), Is.EqualTo(AuditRejectionCode.None)); + } + + // Event type + + [TestCase("auth.unknown")] + [TestCase("AUTH.LOGIN")] + [TestCase("auth.login ")] + [TestCase("")] + public void Validate_WhenEventTypeIsNotInCatalog_RejectsUnknownEventType(string eventType) + { + var draft = new AuditEventDraft(eventType, AuditOutcome.Success); + + Assert.That(_validator.Validate(draft, UserContext), Is.EqualTo(AuditRejectionCode.UnknownEventType)); + } + + // Outcome + + [TestCase(AuditEventTypes.AuthLogout, AuditOutcome.Failure)] + [TestCase(AuditEventTypes.AuthLogin, AuditOutcome.Denied)] + [TestCase(AuditEventTypes.AuditEventsViewed, AuditOutcome.Denied)] + [TestCase(AuditEventTypes.AccessDenied, AuditOutcome.Success)] + public void Validate_WhenOutcomeIsNotAllowedForType_RejectsOutcome(string eventType, AuditOutcome outcome) + { + var draft = new AuditEventDraft(eventType, outcome); + + Assert.That(_validator.Validate(draft, UserContext), Is.EqualTo(AuditRejectionCode.OutcomeNotAllowed)); + } + + [Test] + public void Validate_WhenOutcomeIsUndefinedEnumValue_RejectsOutcome() + { + var draft = StatusChange((AuditOutcome)99); + + Assert.That(_validator.Validate(draft, UserContext), Is.EqualTo(AuditRejectionCode.OutcomeNotAllowed)); + } + + // Actor and correlation + + [Test] + public void Validate_WhenUserActorHasNoId_RejectsContext() + { + var context = new AuditContext(new AuditActor(AuditActorKind.User, null), CorrelationId); + + Assert.That(_validator.Validate(StatusChange(), context), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [Test] + public void Validate_WhenUserActorHasEmptyId_RejectsContext() + { + var context = new AuditContext(new AuditActor(AuditActorKind.User, Guid.Empty), CorrelationId); + + Assert.That(_validator.Validate(StatusChange(), context), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [Test] + public void Validate_WhenAnonymousActorHasId_RejectsContext() + { + var context = new AuditContext(new AuditActor(AuditActorKind.Anonymous, UserId), CorrelationId); + var draft = new AuditEventDraft(AuditEventTypes.AuthLoginThrottled, AuditOutcome.Denied); + + Assert.That(_validator.Validate(draft, context), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [Test] + public void Validate_WhenActorKindIsNotAllowedForOutcome_RejectsContext() + { + // A successful status change always has an authenticated actor. + Assert.That(_validator.Validate(StatusChange(), AnonymousContext), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [Test] + public void Validate_WhenExpiryIsNotFromServiceActor_RejectsContext() + { + var draft = new AuditEventDraft( + AuditEventTypes.CourseDelegationExpired, + AuditOutcome.Success, + CourseId, + TargetAccount, + Details(("action", "audit.events.read"), ("delegationId", DelegationId.ToString()))); + + Assert.That(_validator.Validate(draft, UserContext), Is.EqualTo(AuditRejectionCode.InvalidContext)); + } + + [TestCase("")] + [TestCase("corr 0001")] + [TestCase("corr-0001\nforged line")] + [TestCase("