From 3f535351b3a3289fde78c6fa7b90580477863634 Mon Sep 17 00:00:00 2001 From: Bojana Kovacic Date: Mon, 2 Nov 2026 19:32:06 +0100 Subject: [PATCH] ACC-04 ADM-08: complete KT1 implementation and verification --- .github/workflows/ci.yml | 22 ++ README.md | 12 + .../Modules/Access/AttemptPolicy.cs | 47 +++ .../Modules/Access/AttemptService.cs | 62 ++++ .../Platform.Api/Modules/Access/Contracts.cs | 33 +++ .../Administration/AdministrationPolicy.cs | 42 +++ .../Administration/AdministrationService.cs | 88 ++++++ .../Modules/Administration/Contracts.cs | 27 ++ backend/Platform.Api/Modules/Kt1/DemoStore.cs | 166 +++++++++++ .../Platform.Api/Modules/Kt1/Kt1Endpoints.cs | 119 ++++++++ backend/Platform.Api/Program.cs | 9 + .../Properties/launchSettings.json | 13 + backend/Platform.Kt1.Tests/Kt1HttpTests.cs | 242 +++++++++++++++ .../Platform.Kt1.Tests.csproj | 16 + .../RevocationSessionTests.cs | 50 ++++ .../StoreAndContractTests.cs | 78 +++++ backend/SecurityLab.slnx | 5 + docs/components/coursework/ACC-04.md | 18 ++ docs/components/coursework/ADM-08.md | 26 ++ docs/components/coursework/BACKLOG.md | 38 +++ docs/components/coursework/CLOSURE.md | 51 ++++ docs/components/coursework/CONTRACTS.md | 37 +++ docs/components/coursework/DEMO.md | 48 +++ docs/components/coursework/DOMAIN_MODEL.md | 44 +++ docs/components/coursework/EVIDENCE.json | 49 +++ docs/components/coursework/KT1.md | 49 +++ docs/components/coursework/THREAT_MODEL.md | 23 ++ docs/components/coursework/VALIDATION.md | 36 +++ docs/components/coursework/backlog.json | 280 ++++++++++++++++++ docs/components/coursework/diagrams/README.md | 16 + .../diagrams/acc04-architecture.mmd | 10 + .../diagrams/acc04-architecture.svg | 1 + .../coursework/diagrams/acc04-dfd.mmd | 27 ++ .../coursework/diagrams/acc04-dfd.svg | 1 + .../diagrams/access-admin-domain.mmd | 39 +++ .../diagrams/access-admin-domain.svg | 1 + .../coursework/diagrams/access-admin-flow.mmd | 11 + .../coursework/diagrams/access-admin-flow.svg | 1 + .../diagrams/adm08-architecture.mmd | 10 + .../diagrams/adm08-architecture.svg | 1 + .../coursework/diagrams/adm08-dfd.mmd | 32 ++ .../coursework/diagrams/adm08-dfd.svg | 1 + frontend/src/App.tsx | 2 + frontend/src/components/CourseworkKt1.tsx | 112 +++++++ frontend/src/style.css | 42 +++ scripts/access-demo.ps1 | 3 + scripts/check-kt1.ps1 | 15 + scripts/kt1-demo.ps1 | 121 ++++++++ scripts/prepare-access-task.ps1 | 27 ++ scripts/record-kt1-evidence.ps1 | 88 ++++++ 50 files changed, 2291 insertions(+) create mode 100644 backend/Platform.Api/Modules/Access/AttemptPolicy.cs create mode 100644 backend/Platform.Api/Modules/Access/AttemptService.cs create mode 100644 backend/Platform.Api/Modules/Access/Contracts.cs create mode 100644 backend/Platform.Api/Modules/Administration/AdministrationPolicy.cs create mode 100644 backend/Platform.Api/Modules/Administration/AdministrationService.cs create mode 100644 backend/Platform.Api/Modules/Administration/Contracts.cs create mode 100644 backend/Platform.Api/Modules/Kt1/DemoStore.cs create mode 100644 backend/Platform.Api/Modules/Kt1/Kt1Endpoints.cs create mode 100644 backend/Platform.Api/Properties/launchSettings.json create mode 100644 backend/Platform.Kt1.Tests/Kt1HttpTests.cs create mode 100644 backend/Platform.Kt1.Tests/Platform.Kt1.Tests.csproj create mode 100644 backend/Platform.Kt1.Tests/RevocationSessionTests.cs create mode 100644 backend/Platform.Kt1.Tests/StoreAndContractTests.cs create mode 100644 backend/SecurityLab.slnx create mode 100644 docs/components/coursework/ACC-04.md create mode 100644 docs/components/coursework/ADM-08.md create mode 100644 docs/components/coursework/BACKLOG.md create mode 100644 docs/components/coursework/CLOSURE.md create mode 100644 docs/components/coursework/CONTRACTS.md create mode 100644 docs/components/coursework/DEMO.md create mode 100644 docs/components/coursework/DOMAIN_MODEL.md create mode 100644 docs/components/coursework/EVIDENCE.json create mode 100644 docs/components/coursework/KT1.md create mode 100644 docs/components/coursework/THREAT_MODEL.md create mode 100644 docs/components/coursework/VALIDATION.md create mode 100644 docs/components/coursework/backlog.json create mode 100644 docs/components/coursework/diagrams/README.md create mode 100644 docs/components/coursework/diagrams/acc04-architecture.mmd create mode 100644 docs/components/coursework/diagrams/acc04-architecture.svg create mode 100644 docs/components/coursework/diagrams/acc04-dfd.mmd create mode 100644 docs/components/coursework/diagrams/acc04-dfd.svg create mode 100644 docs/components/coursework/diagrams/access-admin-domain.mmd create mode 100644 docs/components/coursework/diagrams/access-admin-domain.svg create mode 100644 docs/components/coursework/diagrams/access-admin-flow.mmd create mode 100644 docs/components/coursework/diagrams/access-admin-flow.svg create mode 100644 docs/components/coursework/diagrams/adm08-architecture.mmd create mode 100644 docs/components/coursework/diagrams/adm08-architecture.svg create mode 100644 docs/components/coursework/diagrams/adm08-dfd.mmd create mode 100644 docs/components/coursework/diagrams/adm08-dfd.svg create mode 100644 frontend/src/components/CourseworkKt1.tsx create mode 100644 scripts/access-demo.ps1 create mode 100644 scripts/check-kt1.ps1 create mode 100644 scripts/kt1-demo.ps1 create mode 100644 scripts/prepare-access-task.ps1 create mode 100644 scripts/record-kt1-evidence.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4947efb..6b6d4f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,28 @@ jobs: dotnet-version: '10.0.x' - name: Build API starter run: dotnet build backend/Platform.Api/Platform.Api.csproj --configuration Release + - name: ACC-04 and ADM-08 tests + run: dotnet test backend/SecurityLab.slnx --configuration Release --logger "trx;LogFileName=acc04-adm08-kt1.trx" --results-directory TestResults + - name: Live KT1 demonstration + shell: bash + run: | + ASPNETCORE_ENVIRONMENT=Development Kt1Demo__Enabled=true dotnet backend/Platform.Api/bin/Release/net10.0/Platform.Api.dll --urls http://127.0.0.1:5080 > /tmp/kt1-api.log 2>&1 & + api_pid=$! + trap 'kill "$api_pid" 2>/dev/null || true' EXIT + ready=false + for attempt in $(seq 1 40); do + if curl -fsS http://127.0.0.1:5080/api/kt1/info > /dev/null; then ready=true; break; fi + sleep 0.5 + done + if [ "$ready" != true ]; then cat /tmp/kt1-api.log; exit 1; fi + pwsh -NoLogo -NoProfile -File scripts/kt1-demo.ps1 -BaseUrl http://127.0.0.1:5080 + - name: Upload KT1 test evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: acc04-adm08-kt1-results + path: TestResults/ + if-no-files-found: warn frontend: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 485fde1..329229b 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,15 @@ The optional local PostgreSQL service can be started with Docker Compose after c - `docs/templates/PROJECT_COMPONENT.md` — component documentation template. See the course specification for the full requirements. Use short-lived branches and pull requests for changes; keep credentials and other secrets out of the repository. + +## ACC-04 and ADM-08 KT1 implementation + +The started coursework component adds concrete attempt ownership checks and course-scoped administrative delegation through the existing shared `Platform.Access` contract. It includes read/update of an own draft, version checks, two isolated courses, limited delegation/revocation, metadata-only audit, automated negative tests and a React demonstration. + +Start the synthetic API with `dotnet run --project backend/Platform.Api/Platform.Api.csproj --launch-profile AccessKt1`, then run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/kt1-demo.ps1` in a second terminal. The frontend starts with the existing commands above. Docker is not needed for this in-memory demonstration. + +Build/test: `dotnet test backend/SecurityLab.slnx --configuration Release`. The existing backend/frontend CI jobs are retained; the backend job additionally runs the KT1 tests and live demo and uploads their results. A new remote run must still be confirmed after pushing. + +See [the KT1 checklist](docs/components/coursework/KT1.md), [startup and demonstration](docs/components/coursework/DEMO.md), [architecture and DFD diagrams](docs/components/coursework/diagrams), [domain/access matrix](docs/components/coursework/DOMAIN_MODEL.md), [threat model](docs/components/coursework/THREAT_MODEL.md), [provider contracts](docs/components/coursework/CONTRACTS.md), [backlog](docs/components/coursework/BACKLOG.md) and [actual local validation](docs/components/coursework/VALIDATION.md) and [external closure steps](docs/components/coursework/CLOSURE.md). + +The synthetic login is enabled only in Development with `Kt1Demo:Enabled=true`; no production authentication, persistent database or actual external contract acceptance is claimed. Other teams' shared components are preserved. diff --git a/backend/Platform.Api/Modules/Access/AttemptPolicy.cs b/backend/Platform.Api/Modules/Access/AttemptPolicy.cs new file mode 100644 index 0000000..828b60a --- /dev/null +++ b/backend/Platform.Api/Modules/Access/AttemptPolicy.cs @@ -0,0 +1,47 @@ +using Platform.Access.Contracts; +using Platform.Access.Domain.Models; + +namespace Platform.Api.Modules.Access; + +/// Concrete ACC-04 policy, registered in the existing shared default-deny engine. +public sealed class AttemptPolicy(IAccountReader accounts, IMembershipReader memberships, + IAttemptStore attempts) : IAccessPolicy +{ + public const string Read = "attempt.read"; + public const string Update = "attempt.draft.update"; + public string Id => "acc04.ownership"; + public string Version => "acc04:v3"; + + public ValueTask EvaluateAsync(AccessRequest request, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(Resolve(request) == "ALLOW"); + } + + // Internal reasons are used by tests/diagnostics; never returned to an unauthorized HTTP caller. + public string Resolve(AccessRequest request) + { + if (request.Subject is null || !Guid.TryParse(request.Subject.Id, out var actor)) return "NO_IDENTITY"; + if (request.Resource?.Type != "attempt" || !Guid.TryParse(request.Resource.Id, out var id)) return "BAD_RESOURCE"; + if (request.Action != Read && request.Action != Update) return "UNKNOWN_ACTION"; + var account = accounts.FindAccount(actor); + if (account.Status != PortStatus.Ok || account.Value is not { IsActive: true } || account.Value.Id != actor) + return account.Status == PortStatus.Unavailable ? "IDENTITY_UNAVAILABLE" : "ACCOUNT_INACTIVE"; + var resource = attempts.FindAttempt(id); + if (resource.Status != PortStatus.Ok || resource.Value is null) + return resource.Status == PortStatus.Unavailable ? "ATTEMPT_UNAVAILABLE" : "RESOURCE_NOT_FOUND"; + var a = resource.Value; + if (a.Id != id || a.OwnerId == Guid.Empty || a.CourseId == Guid.Empty || a.Version < 1 || !Enum.IsDefined(a.State)) + return "INVALID_SNAPSHOT"; + var member = memberships.FindMembership(actor, a.CourseId); + if (member.Status != PortStatus.Ok || member.Value is null) + return member.Status == PortStatus.Unavailable ? "MEMBERSHIP_UNAVAILABLE" : "NO_MEMBERSHIP"; + var m = member.Value; + if (!m.IsActive || m.AccountId != actor || m.CourseId != a.CourseId || !Enum.IsDefined(m.Role)) return "SCOPE_MISMATCH"; + if (request.Action == Update) + return a.OwnerId == actor && m.Role == CourseRole.Student && a.State == AttemptState.InProgress + ? "ALLOW" : "NOT_EDITABLE_OWNER_DRAFT"; + return a.OwnerId == actor || (m.Role == CourseRole.Teacher && a.State != AttemptState.InProgress) + ? "ALLOW" : "NOT_OWNER_OR_COURSE_TEACHER"; + } +} diff --git a/backend/Platform.Api/Modules/Access/AttemptService.cs b/backend/Platform.Api/Modules/Access/AttemptService.cs new file mode 100644 index 0000000..37f3b1b --- /dev/null +++ b/backend/Platform.Api/Modules/Access/AttemptService.cs @@ -0,0 +1,62 @@ +using Platform.Access.Contracts; +using Platform.Access.Domain.Enums; +using Platform.Access.Domain.Models; + +namespace Platform.Api.Modules.Access; + +public sealed class AttemptService(IAccountReader accounts, IAttemptStore store, + IAccessDecision decisions, ISecurityAudit audit, TimeProvider clock) +{ + public const int MaxAnswerLength = 4000; + public async Task Execute(Guid? actor, Guid id, UpdateDraftRequest? update, + string correlation, CancellationToken ct = default) + { + var action = update is null ? AttemptPolicy.Read : AttemptPolicy.Update; + Guid? course = null; + int? before = null; + OperationResult Finish(int status, string reason, object? value = null, int? after = null) + { + audit.Write(new(clock.GetUtcNow(), correlation, actor, action, id, course, + status == 200 ? "ALLOW" : status == 409 ? "CONFLICT" : "DENY", reason, "acc04:v3", before, after)); + return new(status, value); + } + if (actor is null || actor == Guid.Empty) return Finish(401, "UNAUTHENTICATED"); + if (id == Guid.Empty || (update is not null && (string.IsNullOrWhiteSpace(update.Answer) || + update.Answer.Length > MaxAnswerLength || update.ExpectedVersion < 1))) + return Finish(400, "INVALID_INPUT"); + try + { + var account = accounts.FindAccount(actor.Value); + if (account.Status != PortStatus.Ok || account.Value is not { IsActive: true } || account.Value.Id != actor) + return Finish(404, account.Status == PortStatus.Unavailable ? "IDENTITY_UNAVAILABLE" : "ACCOUNT_INACTIVE"); + var snapshot = store.FindAttempt(id); + if (snapshot.Status != PortStatus.Ok || snapshot.Value is null || snapshot.Value.Id != id) + return Finish(404, snapshot.Status == PortStatus.Unavailable ? "ATTEMPT_UNAVAILABLE" : "RESOURCE_NOT_FOUND"); + var a = snapshot.Value; + course = a.CourseId; + before = a.Version; + var decision = await decisions.DecideAsync(new(new(actor.ToString()!, AccountState.Active), action, + new("attempt", id.ToString()), correlation), ct); + if (decision.Outcome != AccessOutcome.Allow) return Finish(404, "ACCESS_DENIED"); + if (update is null) + { + var read = store.ReadAnswer(a); + return read.Status == PortStatus.Ok && read.Value is not null && read.Value.Id == id && read.Value.Version == a.Version + ? Finish(200, "AUTHORIZED_READ", read.Value) : Finish(404, "SNAPSHOT_UNAVAILABLE"); + } + // Only an already-authorized caller learns about stale versions. + if (update.ExpectedVersion != a.Version) return Finish(409, "VERSION_CONFLICT"); + var result = store.UpdateDraft(a, update.ExpectedVersion, update.Answer!); + return result.Status switch + { + WriteStatus.Updated when result.Value is not null && result.Value.Id == id && result.Value.Version == a.Version + 1 + => Finish(200, "OWNER_DRAFT_UPDATED", result.Value, result.Value.Version), + WriteStatus.Conflict => Finish(409, "VERSION_CONFLICT"), + _ => Finish(404, "DRAFT_STORE_UNAVAILABLE") + }; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception e) when (e is TimeoutException or IOException) + { return Finish(404, "DEPENDENCY_UNAVAILABLE"); } + } +} diff --git a/backend/Platform.Api/Modules/Access/Contracts.cs b/backend/Platform.Api/Modules/Access/Contracts.cs new file mode 100644 index 0000000..197133b --- /dev/null +++ b/backend/Platform.Api/Modules/Access/Contracts.cs @@ -0,0 +1,33 @@ +namespace Platform.Api.Modules.Access; + +public enum PortStatus { Ok, NotFound, Unavailable } +public sealed record PortResult(PortStatus Status, T? Value) where T : class +{ + public static PortResult Found(T value) => new(PortStatus.Ok, value); + public static PortResult Missing() => new(PortStatus.NotFound, null); + public static PortResult Offline() => new(PortStatus.Unavailable, null); +} +public enum CourseRole { Student, Teacher, Assistant } +public enum AttemptState { InProgress, Submitted, Completed } +public enum WriteStatus { Updated, Conflict, Denied, Unavailable } +public sealed record AccountSnapshot(Guid Id, bool IsActive); +public sealed record MembershipSnapshot(Guid AccountId, Guid CourseId, CourseRole Role, bool IsActive); +public sealed record AttemptSnapshot(Guid Id, Guid OwnerId, Guid CourseId, AttemptState State, int Version); +public sealed record AttemptView(Guid Id, string Answer, int Version); +public sealed record UpdateDraftRequest(string? Answer, int ExpectedVersion); +public sealed record OperationResult(int Status, object? Value = null); +public sealed record WriteResult(WriteStatus Status, AttemptView? Value = null); +public sealed record SecurityEvent(DateTimeOffset Timestamp, string CorrelationId, Guid? ActorId, + string Action, Guid TargetId, Guid? CourseId, string Outcome, string Reason, + string PolicyVersion, int? VersionBefore = null, int? VersionAfter = null); + +// The account, membership and attempt modules own these records; consume snapshots, not their tables. +public interface IAccountReader { PortResult FindAccount(Guid id); } +public interface IMembershipReader { PortResult FindMembership(Guid actorId, Guid courseId); } +public interface IAttemptStore +{ + PortResult FindAttempt(Guid id); + PortResult ReadAnswer(AttemptSnapshot authorizedSnapshot); + WriteResult UpdateDraft(AttemptSnapshot authorizedSnapshot, int expectedVersion, string answer); +} +public interface ISecurityAudit { void Write(SecurityEvent entry); } diff --git a/backend/Platform.Api/Modules/Administration/AdministrationPolicy.cs b/backend/Platform.Api/Modules/Administration/AdministrationPolicy.cs new file mode 100644 index 0000000..b93e55c --- /dev/null +++ b/backend/Platform.Api/Modules/Administration/AdministrationPolicy.cs @@ -0,0 +1,42 @@ +using Platform.Access.Contracts; +using Platform.Access.Domain.Models; +using Platform.Api.Modules.Access; + +namespace Platform.Api.Modules.Administration; + +public sealed class AdministrationPolicy(IAccountReader accounts, ICourseAdministrationStore courses) : IAccessPolicy +{ + public const string Read = "course.admin.read"; + public const string Rename = "course.settings.update"; + public const string Delegate = "course.delegation.create"; + public const string Revoke = "course.delegation.revoke"; + public string Id => "adm08.scoped.delegation"; + public string Version => "adm08:v1"; + public static AdminCapability Required(string action) => action switch + { + Read => AdminCapability.ViewAdministration, + Rename => AdminCapability.ManageSettings, + Delegate or Revoke => AdminCapability.ManageDelegations, + _ => AdminCapability.None + }; + public ValueTask EvaluateAsync(AccessRequest request, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (request.Subject is null || !Guid.TryParse(request.Subject.Id, out var actor) || + request.Resource?.Type != "course" || !Guid.TryParse(request.Resource.Id, out var course) || + actor == Guid.Empty || course == Guid.Empty || Required(request.Action) == AdminCapability.None) + return ValueTask.FromResult(false); + var account = accounts.FindAccount(actor); + var c = courses.FindCourse(course); + var grant = courses.FindAuthority(actor, course); + return ValueTask.FromResult(account.Status == PortStatus.Ok && account.Value is { IsActive: true } && + account.Value.Id == actor && c.Status == PortStatus.Ok && c.Value?.Id == course && + grant.Status == PortStatus.Ok && grant.Value is not null && Valid(grant.Value, actor, course, Required(request.Action))); + } + public static bool Valid(AdminGrant grant, Guid actor, Guid course, AdminCapability required) => + grant.Id != Guid.Empty && grant.GranteeId == actor && grant.CourseId == course && + grant.State == GrantState.Active && grant.Version > 0 && + (grant.Capabilities & required) == required && + (grant.Capabilities & ~(AdminCapability.ViewAdministration | AdminCapability.ManageSettings | AdminCapability.ManageDelegations)) == 0 && + (required != AdminCapability.ManageDelegations || grant.IsRoot); +} diff --git a/backend/Platform.Api/Modules/Administration/AdministrationService.cs b/backend/Platform.Api/Modules/Administration/AdministrationService.cs new file mode 100644 index 0000000..0b047dd --- /dev/null +++ b/backend/Platform.Api/Modules/Administration/AdministrationService.cs @@ -0,0 +1,88 @@ +using Platform.Access.Contracts; +using Platform.Access.Domain.Enums; +using Platform.Access.Domain.Models; +using Platform.Api.Modules.Access; + +namespace Platform.Api.Modules.Administration; + +public sealed class AdministrationService(IAccountReader accounts, ICourseAdministrationStore store, + IAccessDecision decisions, ISecurityAudit audit, TimeProvider clock) +{ + public async Task Execute(Guid? actor, Guid course, string action, string correlation, + RenameCourseRequest? rename = null, DelegateRequest? delegation = null, + Guid? grantId = null, RevokeRequest? revoke = null, CancellationToken ct = default) + { + OperationResult Finish(int status, string reason, object? value = null, int? before = null, int? after = null) + { + audit.Write(new(clock.GetUtcNow(), correlation, actor, action, value is GrantView changedGrant ? changedGrant.Id : grantId ?? course, course, + status == 200 ? "ALLOW" : status == 409 ? "CONFLICT" : "DENY", reason, "adm08:v1", before, after)); + return new(status, value); + } + if (actor is null || actor == Guid.Empty) return Finish(401, "UNAUTHENTICATED"); + if (course == Guid.Empty || AdministrationPolicy.Required(action) == AdminCapability.None) + return Finish(400, "INVALID_ACTION_OR_COURSE"); + if (action == AdministrationPolicy.Rename && (rename is null || string.IsNullOrWhiteSpace(rename.DisplayName) || + rename.DisplayName.Length > 100 || rename.ExpectedVersion < 1 || rename.DisplayName.Any(char.IsControl))) + return Finish(400, "INVALID_COURSE_INPUT"); + AdminCapability delegatedCaps = AdminCapability.None; + if (action == AdministrationPolicy.Delegate) + { + if (delegation is null || delegation.GranteeId == Guid.Empty || delegation.GranteeId == actor || + !TryCapabilities(delegation.Capabilities, out delegatedCaps)) return Finish(400, "INVALID_DELEGATION"); + } + if (action == AdministrationPolicy.Revoke && (grantId is null || grantId == Guid.Empty || revoke is null || revoke.ExpectedVersion < 1)) + return Finish(400, "INVALID_REVOCATION"); + try + { + var account = accounts.FindAccount(actor.Value); + if (account.Status != PortStatus.Ok || account.Value is not { IsActive: true } || account.Value.Id != actor) + return Finish(404, account.Status == PortStatus.Unavailable ? "IDENTITY_UNAVAILABLE" : "ACCOUNT_INACTIVE"); + var authority = store.FindAuthority(actor.Value, course); + if (authority.Status != PortStatus.Ok || authority.Value is null) + return Finish(404, authority.Status == PortStatus.Unavailable ? "ADMIN_STORE_UNAVAILABLE" : "NO_COURSE_AUTHORITY"); + var decision = await decisions.DecideAsync(new(new(actor.ToString()!, AccountState.Active), action, + new("course", course.ToString()), correlation), ct); + if (decision.Outcome != AccessOutcome.Allow) return Finish(404, "ACCESS_DENIED"); + // Adapter binds the final read/write to this exact active authority under its lock. + if (action == AdministrationPolicy.Read) + { + var view = store.ReadAdministration(authority.Value); + return view.Status == PortStatus.Ok && view.Value is not null ? Finish(200, "ADMIN_VIEW", view.Value) : Finish(404, "AUTHORITY_CHANGED"); + } + if (delegation is not null) + { + var target = accounts.FindAccount(delegation.GranteeId); + if (target.Status != PortStatus.Ok || target.Value is not { IsActive: true } || target.Value.Id != delegation.GranteeId) + return Finish(404, "DELEGATION_TARGET_UNAVAILABLE"); + } + var result = action switch + { + AdministrationPolicy.Rename => store.Rename(authority.Value, rename!), + AdministrationPolicy.Delegate => store.Delegate(authority.Value, delegation!.GranteeId, delegatedCaps), + AdministrationPolicy.Revoke => store.Revoke(authority.Value, grantId!.Value, revoke!.ExpectedVersion), + _ => new AdminWriteResult(WriteStatus.Denied) + }; + return result.Status switch + { + WriteStatus.Updated => Finish(200, "ADMIN_CHANGE", result.Value, result.Before, result.After), + WriteStatus.Conflict => Finish(409, "VERSION_OR_ACTIVE_GRANT_CONFLICT"), + _ => Finish(404, "AUTHORITY_OR_TARGET_UNAVAILABLE") + }; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception e) when (e is TimeoutException or IOException) + { return Finish(404, "DEPENDENCY_UNAVAILABLE"); } + } + public static bool TryCapabilities(string[]? input, out AdminCapability value) + { + value = AdminCapability.None; + if (input is null || input.Length is < 1 or > 2 || input.Distinct(StringComparer.Ordinal).Count() != input.Length) return false; + foreach (var name in input) + { + if (name == "ViewAdministration") value |= AdminCapability.ViewAdministration; + else if (name == "ManageSettings") value |= AdminCapability.ManageSettings; + else return false; // In particular, a delegate can never receive ManageDelegations or teaching rights. + } + return value.HasFlag(AdminCapability.ViewAdministration); + } +} diff --git a/backend/Platform.Api/Modules/Administration/Contracts.cs b/backend/Platform.Api/Modules/Administration/Contracts.cs new file mode 100644 index 0000000..c464fd0 --- /dev/null +++ b/backend/Platform.Api/Modules/Administration/Contracts.cs @@ -0,0 +1,27 @@ +using Platform.Api.Modules.Access; + +namespace Platform.Api.Modules.Administration; + +[Flags] +public enum AdminCapability { None = 0, ViewAdministration = 1, ManageSettings = 2, ManageDelegations = 4 } +public enum GrantState { Active, Revoked } +public sealed record CourseSnapshot(Guid Id, string DisplayName, int Version); +public sealed record AdminGrant(Guid Id, Guid CourseId, Guid GranteeId, Guid GrantedBy, + AdminCapability Capabilities, GrantState State, int Version, bool IsRoot = false); +public sealed record AdministrationView(Guid CourseId, string DisplayName, int Version, string[] Capabilities); +public sealed record GrantView(Guid Id, Guid CourseId, Guid GranteeId, string[] Capabilities, string State, int Version); +public sealed record RenameCourseRequest(string? DisplayName, int ExpectedVersion); +public sealed record DelegateRequest(Guid GranteeId, string[]? Capabilities); +public sealed record RevokeRequest(int ExpectedVersion); +public sealed record AdminWriteResult(WriteStatus Status, object? Value = null, int? Before = null, int? After = null); + +// Administrative data lives here. Membership, teaching assignments, answers and grades do not. +public interface ICourseAdministrationStore +{ + PortResult FindCourse(Guid course); + PortResult FindAuthority(Guid actor, Guid course); + PortResult ReadAdministration(AdminGrant authority); + AdminWriteResult Rename(AdminGrant authority, RenameCourseRequest request); + AdminWriteResult Delegate(AdminGrant authority, Guid grantee, AdminCapability capabilities); + AdminWriteResult Revoke(AdminGrant authority, Guid grantId, int expectedVersion); +} diff --git a/backend/Platform.Api/Modules/Kt1/DemoStore.cs b/backend/Platform.Api/Modules/Kt1/DemoStore.cs new file mode 100644 index 0000000..81f1e40 --- /dev/null +++ b/backend/Platform.Api/Modules/Kt1/DemoStore.cs @@ -0,0 +1,166 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Platform.Api.Modules.Access; +using Platform.Api.Modules.Administration; + +namespace Platform.Api.Modules.Kt1; + +/// Repeatable process-local simulator. Restart the AccessKt1 profile to reset all state. +public sealed class DemoStore(ILogger logger) : IAccountReader, IMembershipReader, + IAttemptStore, ICourseAdministrationStore, ISecurityAudit +{ + public static Guid Id(int group, int number) => Guid.Parse($"{group}0000000-0000-0000-0000-{number:000000000000}"); + public static readonly Guid Ana = Id(1, 1), Marko = Id(1, 2), Teacher = Id(1, 3), + ForeignTeacher = Id(1, 4), Blocked = Id(1, 5), Assistant = Id(1, 6), + Admin = Id(1, 7), OtherAdmin = Id(1, 8), DelegateUser = Id(1, 9); + public static readonly Guid Course = Id(2, 1), OtherCourse = Id(2, 2), MissingCourse = Id(2, 99); + public static readonly Guid AnaAttempt = Id(3, 1), MarkoAttempt = Id(3, 2), + DraftAttempt = Id(3, 4), CompletedAttempt = Id(3, 5), MissingAttempt = Id(3, 99); + public static readonly Guid RootGrant = Id(4, 1), OtherRootGrant = Id(4, 2); + private readonly object gate = new(); + private readonly Dictionary accounts = new[] + { Ana, Marko, Teacher, ForeignTeacher, Blocked, Assistant, Admin, OtherAdmin, DelegateUser } + .ToDictionary(id => id, id => new AccountSnapshot(id, id != Blocked)); + private readonly Dictionary<(Guid, Guid), MembershipSnapshot> memberships = new() + { + [(Ana, Course)] = new(Ana, Course, CourseRole.Student, true), + [(Marko, Course)] = new(Marko, Course, CourseRole.Student, true), + [(Teacher, Course)] = new(Teacher, Course, CourseRole.Teacher, true), + [(ForeignTeacher, OtherCourse)] = new(ForeignTeacher, OtherCourse, CourseRole.Teacher, true), + [(Blocked, Course)] = new(Blocked, Course, CourseRole.Student, true), + [(Assistant, Course)] = new(Assistant, Course, CourseRole.Assistant, true) + }; + private readonly Dictionary attempts = new() + { + [AnaAttempt] = (new(AnaAttempt, Ana, Course, AttemptState.Submitted, 1), "Synthetic Ana answer."), + [MarkoAttempt] = (new(MarkoAttempt, Marko, Course, AttemptState.Submitted, 1), "Synthetic Marko answer."), + [DraftAttempt] = (new(DraftAttempt, Ana, Course, AttemptState.InProgress, 1), "Synthetic draft."), + [CompletedAttempt] = (new(CompletedAttempt, Ana, Course, AttemptState.Completed, 1), "Synthetic completed answer.") + }; + private readonly Dictionary courses = new() + { [Course] = new(Course, "Security Lab A", 1), [OtherCourse] = new(OtherCourse, "Security Lab B", 1) }; + private readonly Dictionary grants = new() + { + [RootGrant] = new(RootGrant, Course, Admin, Admin, All, GrantState.Active, 1, true), + [OtherRootGrant] = new(OtherRootGrant, OtherCourse, OtherAdmin, OtherAdmin, All, GrantState.Active, 1, true) + }; + private const AdminCapability All = AdminCapability.ViewAdministration | AdminCapability.ManageSettings | AdminCapability.ManageDelegations; + private readonly ConcurrentQueue events = new(); + public IReadOnlyList Events => events.ToArray(); + // Test-double failures are configured in tests only; no HTTP switch can enable them. + public bool AccountsUnavailable { get; set; } + public bool MembershipsUnavailable { get; set; } + public bool AttemptsUnavailable { get; set; } + public bool AdministrationUnavailable { get; set; } + public PortResult FindAccount(Guid id) + { + lock (gate) return AccountsUnavailable ? PortResult.Offline() : + accounts.TryGetValue(id, out var a) ? PortResult.Found(a) : PortResult.Missing(); + } + public void SetAccountActive(Guid id, bool active) + { lock (gate) accounts[id] = new(id, active); } + public PortResult FindMembership(Guid actor, Guid course) + { + lock (gate) return MembershipsUnavailable ? PortResult.Offline() : + memberships.TryGetValue((actor, course), out var m) ? PortResult.Found(m) : PortResult.Missing(); + } + public PortResult FindAttempt(Guid id) + { + lock (gate) return AttemptsUnavailable ? PortResult.Offline() : attempts.TryGetValue(id, out var a) + ? PortResult.Found(a.Snapshot) : PortResult.Missing(); + } + public PortResult ReadAnswer(AttemptSnapshot snapshot) + { + lock (gate) return !AttemptsUnavailable && attempts.TryGetValue(snapshot.Id, out var a) && a.Snapshot == snapshot + ? PortResult.Found(new(snapshot.Id, a.Answer, snapshot.Version)) : PortResult.Missing(); + } + public WriteResult UpdateDraft(AttemptSnapshot snapshot, int expectedVersion, string answer) + { + lock (gate) + { + if (AttemptsUnavailable) return new(WriteStatus.Unavailable); + if (string.IsNullOrWhiteSpace(answer) || answer.Length > AttemptService.MaxAnswerLength) return new(WriteStatus.Denied); + if (!attempts.TryGetValue(snapshot.Id, out var a) || a.Snapshot != snapshot || + snapshot.Version != expectedVersion || snapshot.State != AttemptState.InProgress) return new(WriteStatus.Conflict); + var next = snapshot with { Version = checked(snapshot.Version + 1) }; + attempts[next.Id] = (next, answer); + return new(WriteStatus.Updated, new(next.Id, answer, next.Version)); + } + } + public PortResult FindCourse(Guid course) + { + lock (gate) return AdministrationUnavailable ? PortResult.Offline() : courses.TryGetValue(course, out var c) + ? PortResult.Found(c) : PortResult.Missing(); + } + public PortResult FindAuthority(Guid actor, Guid course) + { + lock (gate) + { + if (AdministrationUnavailable) return PortResult.Offline(); + var a = grants.Values.SingleOrDefault(g => g.CourseId == course && g.GranteeId == actor && g.State == GrantState.Active); + return a is null ? PortResult.Missing() : PortResult.Found(a); + } + } + private bool HasAuthority(AdminGrant a, AdminCapability capability) => !AdministrationUnavailable && !AccountsUnavailable && + accounts.TryGetValue(a.GranteeId, out var account) && account.IsActive && + grants.TryGetValue(a.Id, out var current) && current == a && courses.ContainsKey(a.CourseId) && + AdministrationPolicy.Valid(current, a.GranteeId, a.CourseId, capability); + public static string[] Names(AdminCapability caps) => Enum.GetValues() + .Where(c => c != AdminCapability.None && caps.HasFlag(c)).Select(c => c.ToString()).ToArray(); + public PortResult ReadAdministration(AdminGrant a) + { + lock (gate) + { + if (!HasAuthority(a, AdminCapability.ViewAdministration)) return PortResult.Missing(); + var c = courses[a.CourseId]; + return PortResult.Found(new(c.Id, c.DisplayName, c.Version, Names(a.Capabilities))); + } + } + public AdminWriteResult Rename(AdminGrant a, RenameCourseRequest input) + { + lock (gate) + { + if (!HasAuthority(a, AdminCapability.ManageSettings) || string.IsNullOrWhiteSpace(input.DisplayName) || + input.DisplayName.Length > 100 || input.DisplayName.Any(char.IsControl)) return new(WriteStatus.Denied); + var before = courses[a.CourseId]; + if (before.Version != input.ExpectedVersion) return new(WriteStatus.Conflict); + var next = before with { DisplayName = input.DisplayName.Trim(), Version = checked(before.Version + 1) }; + courses[next.Id] = next; + return new(WriteStatus.Updated, new AdministrationView(next.Id, next.DisplayName, next.Version, Names(a.Capabilities)), before.Version, next.Version); + } + } + public AdminWriteResult Delegate(AdminGrant a, Guid grantee, AdminCapability caps) + { + lock (gate) + { + var allowed = AdminCapability.ViewAdministration | AdminCapability.ManageSettings; + if (!HasAuthority(a, AdminCapability.ManageDelegations) || grantee == a.GranteeId || + !accounts.TryGetValue(grantee, out var account) || !account.IsActive || + !caps.HasFlag(AdminCapability.ViewAdministration) || (caps & ~allowed) != 0) return new(WriteStatus.Denied); + if (grants.Values.Any(g => g.CourseId == a.CourseId && g.GranteeId == grantee && g.State == GrantState.Active)) + return new(WriteStatus.Conflict); + var next = new AdminGrant(Guid.NewGuid(), a.CourseId, grantee, a.GranteeId, caps, GrantState.Active, 1); + grants.Add(next.Id, next); + return new(WriteStatus.Updated, View(next), 0, 1); + } + } + public AdminWriteResult Revoke(AdminGrant a, Guid id, int expectedVersion) + { + lock (gate) + { + if (!HasAuthority(a, AdminCapability.ManageDelegations) || !grants.TryGetValue(id, out var before) || + before.CourseId != a.CourseId || before.IsRoot || before.State != GrantState.Active) return new(WriteStatus.Denied); + if (before.Version != expectedVersion) return new(WriteStatus.Conflict); + var next = before with { State = GrantState.Revoked, Version = checked(before.Version + 1) }; + grants[id] = next; + return new(WriteStatus.Updated, View(next), before.Version, next.Version); + } + } + private static GrantView View(AdminGrant g) => new(g.Id, g.CourseId, g.GranteeId, Names(g.Capabilities), g.State.ToString(), g.Version); + public void Write(SecurityEvent entry) + { + events.Enqueue(entry); + while (events.Count > 256) events.TryDequeue(out _); + logger.LogInformation("KT1_AUDIT_JSON:{Entry}", JsonSerializer.Serialize(entry)); + } +} diff --git a/backend/Platform.Api/Modules/Kt1/Kt1Endpoints.cs b/backend/Platform.Api/Modules/Kt1/Kt1Endpoints.cs new file mode 100644 index 0000000..8c340b7 --- /dev/null +++ b/backend/Platform.Api/Modules/Kt1/Kt1Endpoints.cs @@ -0,0 +1,119 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Platform.Access.Contracts; +using Platform.Access.Infrastructure.Catalog; +using Platform.Access.Infrastructure.Policies; +using Platform.Api.Modules.Access; +using Platform.Api.Modules.Administration; + +namespace Platform.Api.Modules.Kt1; + +public static class Kt1Endpoints +{ + public const string Scheme = "Kt1SyntheticSession"; + public static bool Enabled(IHostEnvironment environment, IConfiguration configuration) => + environment.IsDevelopment() && configuration.GetValue("Kt1Demo:Enabled"); + public static readonly IReadOnlyDictionary Personas = new Dictionary + { + ["ana"] = DemoStore.Ana, ["marko"] = DemoStore.Marko, ["teacher"] = DemoStore.Teacher, + ["foreign-teacher"] = DemoStore.ForeignTeacher, ["blocked"] = DemoStore.Blocked, + ["assistant"] = DemoStore.Assistant, ["admin"] = DemoStore.Admin, + ["other-admin"] = DemoStore.OtherAdmin, ["delegate"] = DemoStore.DelegateUser + }; + public static IServiceCollection AddCourseworkKt1(this IServiceCollection services) + { + services.AddAuthentication(Scheme).AddCookie(Scheme, options => + { + options.Cookie.Name = "SecurityLab.CourseworkKt1"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.ExpireTimeSpan = TimeSpan.FromMinutes(15); + options.SlidingExpiration = false; + options.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = 401; return Task.CompletedTask; }; + }); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(TimeProvider.System); + services.AddScoped(); + services.AddScoped(); + return services; + } + public static void RegisterCourseworkPolicies(this WebApplication app) + { + // Reuse the existing engine/catalog/registry. Do not replace the other team's services. + var catalog = app.Services.GetRequiredService(); + var registry = app.Services.GetRequiredService(); + foreach (var action in new[] { AttemptPolicy.Read, AttemptPolicy.Update }) + { + catalog.Register(action); + registry.Register(action, app.Services.GetRequiredService()); + } + foreach (var action in new[] { AdministrationPolicy.Read, AdministrationPolicy.Rename, AdministrationPolicy.Delegate, AdministrationPolicy.Revoke }) + { + catalog.Register(action); + registry.Register(action, app.Services.GetRequiredService()); + } + } + public static void MapCourseworkKt1(this WebApplication app) + { + var g = app.MapGroup("/api/kt1"); + g.AddEndpointFilter(async (context, next) => + { + var http = context.HttpContext; + http.Response.Headers.CacheControl = "no-store"; + http.Response.Headers["X-Correlation-ID"] = http.TraceIdentifier; + // No CORS is enabled. Non-simple header + Strict cookie protects every demo mutation. + if (!HttpMethods.IsGet(http.Request.Method) && http.Request.Headers["X-Kt1-Demo"] != "1") + return Results.BadRequest(new { code = "INVALID_REQUEST" }); + return await next(context); + }); + g.MapGet("/info", () => new + { + mode = "Synthetic Development-only KT1 simulator. No real credentials or student data.", + requirements = new[] { "ACC-04", "ADM-08" }, personas = Personas.Keys, + attempts = new[] { DemoStore.AnaAttempt, DemoStore.MarkoAttempt, DemoStore.DraftAttempt, DemoStore.CompletedAttempt, DemoStore.MissingAttempt }, + courses = new[] { DemoStore.Course, DemoStore.OtherCourse } + }); + g.MapPost("/session/{persona}", async (string persona, HttpContext http) => + { + if (!Personas.TryGetValue(persona, out var id)) return Results.BadRequest(new { code = "INVALID_PERSONA" }); + await http.SignInAsync(Scheme, new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, id.ToString()) }, Scheme))); + return Results.Ok(new { persona }); + }); + g.MapPost("/logout", async (HttpContext http) => { await http.SignOutAsync(Scheme); return Results.NoContent(); }); + g.MapGet("/attempts/{id}", async (string id, HttpContext http, AttemptService service, CancellationToken ct) => + !ValidId(id, out var parsed) ? BadId() : Public(await service.Execute(Actor(http), parsed, null, http.TraceIdentifier, ct))); + g.MapPut("/attempts/{id}/answer", async (string id, UpdateDraftRequest input, HttpContext http, AttemptService service, CancellationToken ct) => + !ValidId(id, out var parsed) ? BadId() : Public(await service.Execute(Actor(http), parsed, input, http.TraceIdentifier, ct))); + g.MapGet("/courses/{id}/administration", async (string id, HttpContext http, AdministrationService service, CancellationToken ct) => + !ValidId(id, out var parsed) ? BadId() : Public(await service.Execute(Actor(http), parsed, AdministrationPolicy.Read, http.TraceIdentifier, ct: ct))); + g.MapPut("/courses/{id}/settings", async (string id, RenameCourseRequest input, HttpContext http, AdministrationService service, CancellationToken ct) => + !ValidId(id, out var parsed) ? BadId() : Public(await service.Execute(Actor(http), parsed, AdministrationPolicy.Rename, http.TraceIdentifier, rename: input, ct: ct))); + g.MapPost("/courses/{id}/delegations", async (string id, DelegateRequest input, HttpContext http, AdministrationService service, CancellationToken ct) => + !ValidId(id, out var parsed) ? BadId() : Public(await service.Execute(Actor(http), parsed, AdministrationPolicy.Delegate, http.TraceIdentifier, delegation: input, ct: ct))); + g.MapPost("/courses/{id}/delegations/{grantId}/revoke", async (string id, string grantId, RevokeRequest input, HttpContext http, AdministrationService service, CancellationToken ct) => + !ValidId(id, out var parsed) || !ValidId(grantId, out var grant) ? BadId() : + Public(await service.Execute(Actor(http), parsed, AdministrationPolicy.Revoke, http.TraceIdentifier, grantId: grant, revoke: input, ct: ct))); + } + private static Guid? Actor(HttpContext http) => http.User.Identity?.IsAuthenticated == true && + Guid.TryParse(http.User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; + private static bool ValidId(string id, out Guid value) => Guid.TryParse(id, out value) && value != Guid.Empty; + private static IResult BadId() => Results.Json(new { code = "INVALID_REQUEST" }, statusCode: 400); + private static IResult Public(OperationResult result) => result.Status switch + { + 200 => Results.Ok(result.Value), + 400 => Results.Json(new { code = "INVALID_REQUEST" }, statusCode: 400), + 401 => Results.Json(new { code = "AUTHENTICATION_REQUIRED" }, statusCode: 401), + 409 => Results.Json(new { code = "RESOURCE_CHANGED" }, statusCode: 409), + _ => Results.Json(new { code = "RESOURCE_UNAVAILABLE" }, statusCode: 404) + }; +} diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index db0907c..7531965 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -1,3 +1,4 @@ +using Platform.Api.Modules.Kt1; using Platform.Access.Contracts; using Platform.Access.Composition; using Platform.Access.Domain.Enums; @@ -5,8 +6,16 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddAccessControl(); +var kt1Enabled = Kt1Endpoints.Enabled(builder.Environment, builder.Configuration); +if (kt1Enabled) builder.Services.AddCourseworkKt1(); var app = builder.Build(); +if (kt1Enabled) +{ + app.UseAuthentication(); + app.RegisterCourseworkPolicies(); + app.MapCourseworkKt1(); +} // Starter endpoint only. Add domain APIs together with server-side authorization. app.MapGet("/api/health", () => Results.Ok(new diff --git a/backend/Platform.Api/Properties/launchSettings.json b/backend/Platform.Api/Properties/launchSettings.json new file mode 100644 index 0000000..9043827 --- /dev/null +++ b/backend/Platform.Api/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "AccessKt1": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "Kt1Demo__Enabled": "true" + } + } + } +} diff --git a/backend/Platform.Kt1.Tests/Kt1HttpTests.cs b/backend/Platform.Kt1.Tests/Kt1HttpTests.cs new file mode 100644 index 0000000..5767753 --- /dev/null +++ b/backend/Platform.Kt1.Tests/Kt1HttpTests.cs @@ -0,0 +1,242 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Platform.Api.Modules.Access; +using Platform.Api.Modules.Administration; +using Platform.Api.Modules.Kt1; + +namespace Platform.Kt1.Tests; + +public sealed class Kt1Factory(string environment = "Development", bool enabled = true) : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) => + builder.UseEnvironment(environment).UseSetting("Kt1Demo:Enabled", enabled.ToString()); +} +[TestFixture] +public class Kt1HttpTests +{ + private Kt1Factory factory = null!; + private HttpClient client = null!; + private DemoStore Store => factory.Services.GetRequiredService(); + private static string Attempt(Guid id) => $"/api/kt1/attempts/{id}"; + private static string Course(Guid id) => $"/api/kt1/courses/{id}"; + [SetUp] public void Setup() { factory = new(); client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Kt1-Demo", "1"); } + [TearDown] public void Cleanup() { client.Dispose(); factory.Dispose(); } + private async Task Login(string persona) => Assert.That((await client.PostAsync($"/api/kt1/session/{persona}", null)).StatusCode, Is.EqualTo(HttpStatusCode.OK)); + private async Task Grant(params string[] caps) + { + await Login("admin"); + var result = await client.PostAsJsonAsync(Course(DemoStore.Course) + "/delegations", new DelegateRequest(DemoStore.DelegateUser, caps)); + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + return (await result.Content.ReadFromJsonAsync())!; + } + [TestCase("Development", false)] [TestCase("Production", true)] + public async Task SyntheticRoutesRequireDevelopmentAndExplicitFlag(string environment, bool enabled) + { + using var isolated = new Kt1Factory(environment, enabled); + using var c = isolated.CreateClient(); + Assert.That((await c.GetAsync("/api/health")).StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That((await c.GetAsync("/api/kt1/info")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That((await c.PostAsync("/api/kt1/session/admin", null)).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + [Test] public async Task SharedTeamDefaultDenyDemoIsPreserved() + { + var body = await client.GetStringAsync("/api/access/demo-deny"); + Assert.That(body, Does.Contain("denialReason")); + Assert.That(body, Does.Not.Contain("Synthetic Ana answer")); + } + [TestCase("ana", 200)] [TestCase("marko", 404)] [TestCase("teacher", 200)] + [TestCase("foreign-teacher", 404)] [TestCase("blocked", 404)] [TestCase("assistant", 404)] + [TestCase("admin", 404)] [TestCase("other-admin", 404)] [TestCase("delegate", 404)] + public async Task ConcreteReadChecksOwnershipMembershipAndTeachingRole(string persona, int expected) + { + await Login(persona); + Assert.That((int)(await client.GetAsync(Attempt(DemoStore.AnaAttempt))).StatusCode, Is.EqualTo(expected)); + } + [Test] public async Task AnonymousReadAndWriteRequireSession() + { + Assert.That((await client.GetAsync(Attempt(DemoStore.DraftAttempt))).StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + Assert.That((await client.PutAsJsonAsync(Attempt(DemoStore.DraftAttempt) + "/answer", new UpdateDraftRequest("x", 1))).StatusCode, + Is.EqualTo(HttpStatusCode.Unauthorized)); + Assert.That((await client.GetAsync(Course(DemoStore.Course) + "/administration")).StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + [TestCase(false)] [TestCase(true)] + public async Task ForeignAndMissingAreIdenticalEvenWithMembershipOutage(bool offline) + { + await Login("ana"); Store.MembershipsUnavailable = offline; + var foreign = await client.GetAsync(Attempt(DemoStore.MarkoAttempt)); + var missing = await client.GetAsync(Attempt(DemoStore.MissingAttempt)); + Assert.That(foreign.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(await foreign.Content.ReadAsStringAsync(), Is.EqualTo(await missing.Content.ReadAsStringAsync())); + Assert.That(foreign.Headers.CacheControl?.NoStore, Is.True); + } + [TestCase("identity")] [TestCase("attempts")] [TestCase("membership")] + public async Task DependencyOutageDeniesOwnerReadAndWriteWithoutMutation(string dependency) + { + await Login("ana"); + Store.AccountsUnavailable = dependency == "identity"; + Store.AttemptsUnavailable = dependency == "attempts"; + Store.MembershipsUnavailable = dependency == "membership"; + Assert.That((await client.GetAsync(Attempt(DemoStore.DraftAttempt))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That((await client.PutAsJsonAsync(Attempt(DemoStore.DraftAttempt) + "/answer", new UpdateDraftRequest("secret", 1))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + Store.AccountsUnavailable = Store.AttemptsUnavailable = Store.MembershipsUnavailable = false; + Assert.That(Store.FindAttempt(DemoStore.DraftAttempt).Value!.Version, Is.EqualTo(1)); + } + [Test] public async Task DraftUpdatePersistsAndStaleVersionFailsWithMetadataOnlyAudit() + { + await Login("ana"); + const string answer = "Do not put this answer into the audit."; + var response = await client.PutAsJsonAsync(Attempt(DemoStore.DraftAttempt) + "/answer", new UpdateDraftRequest(answer, 1)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var value = await client.GetFromJsonAsync(Attempt(DemoStore.DraftAttempt)); + Assert.That(value!.Answer, Is.EqualTo(answer)); Assert.That(value.Version, Is.EqualTo(2)); + Assert.That((await client.PutAsJsonAsync(Attempt(DemoStore.DraftAttempt) + "/answer", new UpdateDraftRequest("lost update", 1))).StatusCode, + Is.EqualTo(HttpStatusCode.Conflict)); + var entry = Store.Events.Single(e => e.Reason == "OWNER_DRAFT_UPDATED"); + Assert.That(entry.VersionBefore, Is.EqualTo(1)); Assert.That(entry.VersionAfter, Is.EqualTo(2)); + Assert.That(entry.CorrelationId, Is.EqualTo(response.Headers.GetValues("X-Correlation-ID").Single())); + Assert.That(JsonSerializer.Serialize(Store.Events), Does.Not.Contain(answer)); + } + [TestCase("marko")] [TestCase("teacher")] [TestCase("admin")] [TestCase("assistant")] [TestCase("blocked")] + public async Task NonOwnerCannotWriteAndLearnsNoVersionConflict(string persona) + { + await Login(persona); + Assert.That((await client.PutAsJsonAsync(Attempt(DemoStore.DraftAttempt) + "/answer", new UpdateDraftRequest("x", 999))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(Store.FindAttempt(DemoStore.DraftAttempt).Value!.Version, Is.EqualTo(1)); + } + [TestCase(1)] [TestCase(5)] + public async Task SubmittedAndCompletedAttemptsAreImmutable(int number) + { + await Login("ana"); + Assert.That((await client.PutAsJsonAsync(Attempt(DemoStore.Id(3, number)) + "/answer", new UpdateDraftRequest("x", 1))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + } + [TestCase(null, 1)] [TestCase("", 1)] [TestCase(" ", 1)] [TestCase("answer", 0)] + public async Task InvalidDraftInputIsRejected(string? answer, int version) + { + await Login("ana"); + Assert.That((await client.PutAsJsonAsync(Attempt(DemoStore.DraftAttempt) + "/answer", new UpdateDraftRequest(answer, version))).StatusCode, + Is.EqualTo(HttpStatusCode.BadRequest)); + } + [Test] public async Task UnsubmittedAnswerStaysPrivateFromTeacher() + { await Login("teacher"); Assert.That((await client.GetAsync(Attempt(DemoStore.DraftAttempt))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } + [Test] public async Task MissingMutationHeaderCannotChangeSessionOrDraft() + { + await Login("ana"); client.DefaultRequestHeaders.Remove("X-Kt1-Demo"); + Assert.That((await client.PostAsync("/api/kt1/session/admin", null)).StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That((await client.PutAsJsonAsync(Attempt(DemoStore.DraftAttempt) + "/answer", new UpdateDraftRequest("x", 1))).StatusCode, + Is.EqualTo(HttpStatusCode.BadRequest)); + } + [Test] public async Task ForgedActorHeadersDoNotElevateStudent() + { + await Login("marko"); client.DefaultRequestHeaders.Add("X-Account-Id", DemoStore.Ana.ToString()); + client.DefaultRequestHeaders.Add("X-Role", "Admin"); + Assert.That((await client.GetAsync(Attempt(DemoStore.AnaAttempt))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That((await client.GetAsync(Course(DemoStore.Course) + "/administration")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + [TestCase("teacher")] [TestCase("ana")] [TestCase("other-admin")] [TestCase("delegate")] + public async Task CourseAdministrationRequiresExplicitGrantInThisCourse(string persona) + { + await Login(persona); + Assert.That((await client.GetAsync(Course(DemoStore.Course) + "/administration")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.Course) + "/delegations", + new DelegateRequest(DemoStore.Marko, ["ViewAdministration"]))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + [Test] public async Task DelegationRenameRevokeIsScopedAndImmediatelyEffective() + { + // The original client stays the administrator; the delegate keeps a separate existing session. + var grant = await Grant("ViewAdministration", "ManageSettings"); + Assert.That(Store.Events.Single(e => e.Action == AdministrationPolicy.Delegate && e.Outcome == "ALLOW").TargetId, Is.EqualTo(grant.Id)); + using var delegateClient = factory.CreateClient(); + delegateClient.DefaultRequestHeaders.Add("X-Kt1-Demo", "1"); + Assert.That((await delegateClient.PostAsync("/api/kt1/session/delegate", null)).StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var initial = await delegateClient.GetFromJsonAsync(Course(DemoStore.Course) + "/administration"); + Assert.That(initial!.Capabilities, Does.Not.Contain("ManageDelegations")); + Assert.That((await delegateClient.PutAsJsonAsync(Course(DemoStore.Course) + "/settings", new RenameCourseRequest("Delegated title", initial.Version))).StatusCode, + Is.EqualTo(HttpStatusCode.OK)); + Assert.That((await delegateClient.GetAsync(Course(DemoStore.OtherCourse) + "/administration")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That((await delegateClient.GetAsync(Attempt(DemoStore.AnaAttempt))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That((await delegateClient.PostAsJsonAsync(Course(DemoStore.Course) + "/delegations", new DelegateRequest(DemoStore.Marko, ["ViewAdministration"]))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.Course) + $"/delegations/{grant.Id}/revoke", new RevokeRequest(grant.Version))).StatusCode, + Is.EqualTo(HttpStatusCode.OK)); + // No sign-in call after revocation: these are requests from the previously authenticated client. + Assert.That((await delegateClient.GetAsync(Course(DemoStore.Course) + "/administration")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That((await delegateClient.PutAsJsonAsync(Course(DemoStore.Course) + "/settings", new RenameCourseRequest("after revoke", 2))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(Store.FindCourse(DemoStore.Course).Value!.DisplayName, Is.EqualTo("Delegated title")); + Assert.That(Store.Events.Count(e => e.Action == AdministrationPolicy.Revoke && e.Outcome == "ALLOW"), Is.EqualTo(1)); + } + [Test] public async Task ViewOnlyDelegateCannotRename() + { + await Grant("ViewAdministration"); await Login("delegate"); + Assert.That((await client.GetAsync(Course(DemoStore.Course) + "/administration")).StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That((await client.PutAsJsonAsync(Course(DemoStore.Course) + "/settings", new RenameCourseRequest("forbidden", 1))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + } + [TestCase("ManageDelegations")] [TestCase("Teacher")] [TestCase("*")] [TestCase("ManageSettings")] + public async Task InvalidOrEscalatingCapabilitySetIsRejected(string capability) + { + await Login("admin"); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.Course) + "/delegations", new DelegateRequest(DemoStore.DelegateUser, [capability]))).StatusCode, + Is.EqualTo(HttpStatusCode.BadRequest)); + } + [Test] public async Task RootIsCourseScopedAndCannotRevokeOtherCourseOrRootGrant() + { + var grant = await Grant("ViewAdministration"); + await Login("other-admin"); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.OtherCourse) + $"/delegations/{grant.Id}/revoke", new RevokeRequest(1))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + await Login("admin"); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.Course) + $"/delegations/{DemoStore.RootGrant}/revoke", new RevokeRequest(1))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + } + [Test] public async Task BlockedDelegateLosesAuthorityEvenWithExistingCookie() + { + await Grant("ViewAdministration"); await Login("delegate"); Store.SetAccountActive(DemoStore.DelegateUser, false); + Assert.That((await client.GetAsync(Course(DemoStore.Course) + "/administration")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + [Test] public async Task AdminOutageIsFailClosedAndDoesNotRevealMissingCourse() + { + await Login("admin"); Store.AdministrationUnavailable = true; + var a = await client.GetAsync(Course(DemoStore.Course) + "/administration"); + var b = await client.GetAsync(Course(DemoStore.MissingCourse) + "/administration"); + Assert.That(a.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(await a.Content.ReadAsStringAsync(), Is.EqualTo(await b.Content.ReadAsStringAsync())); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.Course) + "/delegations", new DelegateRequest(DemoStore.DelegateUser, ["ViewAdministration"]))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + } + [Test] public async Task SelfDelegationAndInactiveTargetAreRejected() + { + await Login("admin"); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.Course) + "/delegations", new DelegateRequest(DemoStore.Admin, ["ViewAdministration"]))).StatusCode, + Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.Course) + "/delegations", new DelegateRequest(DemoStore.Blocked, ["ViewAdministration"]))).StatusCode, + Is.EqualTo(HttpStatusCode.NotFound)); + } + [Test] public async Task DuplicateDelegationAndStaleRevokeFailWithoutLosingGrant() + { + var grant = await Grant("ViewAdministration"); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.Course) + "/delegations", new DelegateRequest(DemoStore.DelegateUser, ["ViewAdministration"]))).StatusCode, + Is.EqualTo(HttpStatusCode.Conflict)); + Assert.That((await client.PostAsJsonAsync(Course(DemoStore.Course) + $"/delegations/{grant.Id}/revoke", new RevokeRequest(999))).StatusCode, + Is.EqualTo(HttpStatusCode.Conflict)); + Assert.That(Store.FindAuthority(DemoStore.DelegateUser, DemoStore.Course).Value!.State, Is.EqualTo(GrantState.Active)); + } + [TestCase("")] [TestCase("\ninvalid")] + public async Task CourseNameValidationAndStaleVersionPreventMutation(string name) + { + await Login("admin"); + Assert.That((await client.PutAsJsonAsync(Course(DemoStore.Course) + "/settings", new RenameCourseRequest(name, 1))).StatusCode, + Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That((await client.PutAsJsonAsync(Course(DemoStore.Course) + "/settings", new RenameCourseRequest("good", 999))).StatusCode, + Is.EqualTo(HttpStatusCode.Conflict)); + Assert.That(Store.FindCourse(DemoStore.Course).Value!.Version, Is.EqualTo(1)); + } +} diff --git a/backend/Platform.Kt1.Tests/Platform.Kt1.Tests.csproj b/backend/Platform.Kt1.Tests/Platform.Kt1.Tests.csproj new file mode 100644 index 0000000..c59cc5c --- /dev/null +++ b/backend/Platform.Kt1.Tests/Platform.Kt1.Tests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + + diff --git a/backend/Platform.Kt1.Tests/RevocationSessionTests.cs b/backend/Platform.Kt1.Tests/RevocationSessionTests.cs new file mode 100644 index 0000000..a6a56a8 --- /dev/null +++ b/backend/Platform.Kt1.Tests/RevocationSessionTests.cs @@ -0,0 +1,50 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Platform.Api.Modules.Administration; +using Platform.Api.Modules.Kt1; + +namespace Platform.Kt1.Tests; + +[TestFixture] +public class RevocationSessionTests +{ + [TestCase(false)] + [TestCase(true)] + public async Task RevocationRejectsTheSamePreviouslyIssuedCookie(bool write) + { + using var factory = new Kt1Factory(); + using var admin = factory.CreateClient(); + // Disable automatic cookies so the exact pre-revocation cookie is explicitly reused. + using var delegated = factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + admin.DefaultRequestHeaders.Add("X-Kt1-Demo", "1"); + delegated.DefaultRequestHeaders.Add("X-Kt1-Demo", "1"); + Assert.That((await admin.PostAsync("/api/kt1/session/admin", null)).StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var course = $"/api/kt1/courses/{DemoStore.Course}"; + var created = await admin.PostAsJsonAsync(course + "/delegations", + new DelegateRequest(DemoStore.DelegateUser, ["ViewAdministration", "ManageSettings"])); + Assert.That(created.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var grant = (await created.Content.ReadFromJsonAsync())!; + var signedIn = await delegated.PostAsync("/api/kt1/session/delegate", null); + Assert.That(signedIn.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var issuedCookie = signedIn.Headers.GetValues("Set-Cookie") + .Single(c => c.StartsWith("SecurityLab.CourseworkKt1=", StringComparison.Ordinal)).Split(';', 2)[0]; + delegated.DefaultRequestHeaders.Add("Cookie", issuedCookie); + Assert.That((await delegated.GetAsync(course + "/administration")).StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var store = factory.Services.GetRequiredService(); + var before = store.FindCourse(DemoStore.Course).Value!; + Assert.That((await admin.PostAsJsonAsync(course + $"/delegations/{grant.Id}/revoke", new RevokeRequest(grant.Version))).StatusCode, + Is.EqualTo(HttpStatusCode.OK)); + // No fresh sign-in and no automatic Set-Cookie processing can change the delegated session. + var denied = write + ? await delegated.PutAsJsonAsync(course + "/settings", new RenameCourseRequest("must not be stored", before.Version)) + : await delegated.GetAsync(course + "/administration"); + Assert.That(denied.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(await denied.Content.ReadAsStringAsync(), Is.EqualTo("{\"code\":\"RESOURCE_UNAVAILABLE\"}")); + Assert.That(store.FindCourse(DemoStore.Course).Value, Is.EqualTo(before)); + Assert.That(store.FindAuthority(DemoStore.DelegateUser, DemoStore.Course).Value, Is.Null); + Assert.That(store.Events.Last().ActorId, Is.EqualTo(DemoStore.DelegateUser)); + } +} diff --git a/backend/Platform.Kt1.Tests/StoreAndContractTests.cs b/backend/Platform.Kt1.Tests/StoreAndContractTests.cs new file mode 100644 index 0000000..5e65e60 --- /dev/null +++ b/backend/Platform.Kt1.Tests/StoreAndContractTests.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Platform.Access.Contracts; +using Platform.Access.Domain.Enums; +using Platform.Access.Domain.Models; +using Platform.Api.Modules.Access; +using Platform.Api.Modules.Administration; +using Platform.Api.Modules.Kt1; +using Microsoft.Extensions.DependencyInjection; + +namespace Platform.Kt1.Tests; + +[TestFixture] +public class StoreAndContractTests +{ + private static DemoStore Seed() => new(NullLogger.Instance); + [Test] public void RepeatedSeedHasSameOwnershipAndAdministrativeBoundaries() + { + var a = Seed(); var b = Seed(); + Assert.That(a.FindAttempt(DemoStore.DraftAttempt), Is.EqualTo(b.FindAttempt(DemoStore.DraftAttempt))); + Assert.That(a.FindAuthority(DemoStore.Admin, DemoStore.Course).Value!.IsRoot, Is.True); + Assert.That(a.FindAuthority(DemoStore.Admin, DemoStore.OtherCourse).Status, Is.EqualTo(PortStatus.NotFound)); + } + [Test] public void ConcurrentDraftWritesWithSameVersionHaveExactlyOneWinner() + { + var store = Seed(); var snapshot = store.FindAttempt(DemoStore.DraftAttempt).Value!; + var results = new System.Collections.Concurrent.ConcurrentBag(); + Parallel.For(0, 16, i => results.Add(store.UpdateDraft(snapshot, 1, $"answer {i}").Status)); + Assert.That(results.Count(s => s == WriteStatus.Updated), Is.EqualTo(1)); + Assert.That(results.Count(s => s == WriteStatus.Conflict), Is.EqualTo(15)); + Assert.That(store.FindAttempt(snapshot.Id).Value!.Version, Is.EqualTo(2)); + Assert.That(store.ReadAnswer(snapshot).Status, Is.EqualTo(PortStatus.NotFound)); + } + [Test] public void RevocationRejectsPreviouslyCapturedAuthorityAtCommitBoundary() + { + var store = Seed(); var root = store.FindAuthority(DemoStore.Admin, DemoStore.Course).Value!; + var delegated = (GrantView)store.Delegate(root, DemoStore.DelegateUser, + AdminCapability.ViewAdministration | AdminCapability.ManageSettings).Value!; + var captured = store.FindAuthority(DemoStore.DelegateUser, DemoStore.Course).Value!; + Assert.That(store.Revoke(root, delegated.Id, delegated.Version).Status, Is.EqualTo(WriteStatus.Updated)); + Assert.That(store.Rename(captured, new("stale authority", 1)).Status, Is.EqualTo(WriteStatus.Denied)); + Assert.That(store.ReadAdministration(captured).Status, Is.EqualTo(PortStatus.NotFound)); + } + [Test] public void AdministrativeGrantDoesNotCreateTeachingMembership() + { + var store = Seed(); var root = store.FindAuthority(DemoStore.Admin, DemoStore.Course).Value!; + store.Delegate(root, DemoStore.DelegateUser, AdminCapability.ViewAdministration); + Assert.That(store.FindMembership(DemoStore.DelegateUser, DemoStore.Course).Status, Is.EqualTo(PortStatus.NotFound)); + Assert.That(store.FindAttempt(DemoStore.AnaAttempt).Value!.OwnerId, Is.EqualTo(DemoStore.Ana)); + } + [Test] public async Task SharedDefaultDenyContractHandlesUnknownActionAndNoSubject() + { + using var factory = new Kt1Factory(); + var engine = factory.Services.GetRequiredService(); + var unknown = await engine.DecideAsync(new(new(DemoStore.Ana.ToString(), AccountState.Active), "attempt.delete", new("attempt", DemoStore.AnaAttempt.ToString()), "contract-unknown")); + Assert.That(unknown.Outcome, Is.EqualTo(AccessOutcome.Deny)); + Assert.That(unknown.DenialReason, Is.EqualTo(AccessDenialReason.UnknownAction)); + var anonymous = await engine.DecideAsync(new(null, AttemptPolicy.Read, new("attempt", DemoStore.AnaAttempt.ToString()), "contract-anonymous")); + Assert.That(anonymous.DenialReason, Is.EqualTo(AccessDenialReason.Unauthenticated)); + } + [Test] public void ProviderContractSupportsFoundMissingAndUnavailableWithoutAnswerInMetadata() + { + IAttemptStore port = Seed(); + Assert.That(port.FindAttempt(DemoStore.AnaAttempt).Status, Is.EqualTo(PortStatus.Ok)); + Assert.That(port.FindAttempt(DemoStore.MissingAttempt).Status, Is.EqualTo(PortStatus.NotFound)); + ((DemoStore)port).AttemptsUnavailable = true; + Assert.That(port.FindAttempt(DemoStore.AnaAttempt).Status, Is.EqualTo(PortStatus.Unavailable)); + Assert.That(typeof(AttemptSnapshot).GetProperty("Answer"), Is.Null); + } + [TestCase("attempt.read", "course")] [TestCase("course.admin.read", "attempt")] + public async Task ResourceTypeCannotBeConfusedAcrossPolicies(string action, string type) + { + using var factory = new Kt1Factory(); + var decision = await factory.Services.GetRequiredService().DecideAsync(new( + new(DemoStore.Admin.ToString(), AccountState.Active), action, new(type, DemoStore.Course.ToString()), "wrong-type")); + Assert.That(decision.Outcome, Is.EqualTo(AccessOutcome.Deny)); + } +} diff --git a/backend/SecurityLab.slnx b/backend/SecurityLab.slnx new file mode 100644 index 0000000..4bbd24e --- /dev/null +++ b/backend/SecurityLab.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/components/coursework/ACC-04.md b/docs/components/coursework/ACC-04.md new file mode 100644 index 0000000..50635b8 --- /dev/null +++ b/docs/components/coursework/ACC-04.md @@ -0,0 +1,18 @@ +# ACC-04 — Concrete-resource authorization (J) + +Specification §4.2: a student must not read another student's attempt even via a direct URL. The selected resource is an attempt; its owner and course are resolved server-side. + +| Use case derived from the requirement | Current flow | Evidence / prerequisite | +|---|---|---| +| Read own attempt | Cookie subject → current active account → attempt metadata → current course membership → shared decision → snapshot-bound answer read | `ConcreteReadChecksOwnershipMembershipAndTeachingRole`; trusted account/membership/ATT adapters | +| Reject direct URL/ID substitution | Foreign and missing attempt return the same public 404 JSON | `ForeignAndMissingAreIdenticalEvenWithMembershipOutage` | +| Read submitted attempt as course teacher | Active Teacher membership in the same course; unsubmitted answer remains private | `UnsubmittedAnswerStaysPrivateFromTeacher`, read matrix | +| Update own unsubmitted draft | Student owner, active membership, InProgress, nonblank ≤4000 characters, expected version; one atomic write | `DraftUpdatePersistsAndStaleVersionFailsWithMetadataOnlyAudit`, CAS test | +| Preserve historical states | Submitted and Completed attempts cannot be overwritten | `SubmittedAndCompletedAttemptsAreImmutable` | +| Reject missing/blocked identity, missing membership, provider outage | Fail closed; denial has no answer or internal reason | `DependencyOutageDeniesOwnerReadAndWriteWithoutMutation` | + +The update operation is an explicit starter use case for demonstrating a second primary operation. It is not an implementation of submissions, evaluation, grading, or all ATT features. Teacher membership is consumed from its provider; this code does not assign teaching roles. Assistants have no implicit review power. Administrative grants do not grant answer access. + +Actions: `attempt.read`, `attempt.draft.update`; resource type `attempt`; policy `acc04.ownership`, version `acc04:v3`. + +Architecture and DFD: `diagrams/acc04-architecture.svg` and `diagrams/acc04-dfd.svg`. See `BACKLOG.md` for order/prerequisites and `CONTRACTS.md` for provider ownership. diff --git a/docs/components/coursework/ADM-08.md b/docs/components/coursework/ADM-08.md new file mode 100644 index 0000000..4ebc3eb --- /dev/null +++ b/docs/components/coursework/ADM-08.md @@ -0,0 +1,26 @@ +# ADM-08 — Multi-course administration with delegation (N) + +Specification §4.12: separation of administrative and teaching power must be verifiable. Two independent courses with explicit root administrative grants are seeded. A teacher is not an administrator merely because they teach a course; a course administrator cannot read student answers merely because they administer that course. + +| Use case derived from the requirement | Implemented starter flow | Evidence / prerequisite | +|---|---|---| +| Inspect authorized course administration | Current active account + explicit active grant in exactly that course | `CourseAdministrationRequiresExplicitGrantInThisCourse` | +| Delegate limited course administration | Root course administrator grants ViewAdministration, optionally ManageSettings, to an existing active account | `DelegationRenameRevokeIsScopedAndImmediatelyEffective` | +| Edit permitted administrative metadata | Root or delegate with ManageSettings changes only display name; current course version is required | End-to-end delegation test, `CourseNameValidationAndStaleVersionPreventMutation` | +| Revoke delegation | Root for that same course revokes an active non-root grant with matching version | Root/cross-course and stale-revocation tests | +| Apply revocation without re-login | Every request re-resolves authority; final store read/write compares its exact active grant version | `RevocationRejectsPreviouslyCapturedAuthorityAtCommitBoundary`, existing-session HTTP test | +| Preserve teaching/student boundary | Grant does not create a course membership or change attempt ownership | `AdministrativeGrantDoesNotCreateTeachingMembership`; admin/delegate answer-denial scenarios | + +This is permanent-until-revoked **administrative** delegation in a specific course. No expiration/window engine, temporary teaching delegation, role-assignment API, grading API or root-grant provisioning API is claimed. Those belong to their actual owners or future agreed integrations. + +Capabilities: + +- `ViewAdministration`: read course ID, display name, version and the caller's effective administrative capability names; never answers, grades or student lists. +- `ManageSettings`: change only course display name, with version check. +- `ManageDelegations`: seeded root administrators only. This capability cannot be delegated. No self-grant, redelegation, cross-course revocation or root revocation is allowed. + +Actions: `course.admin.read`, `course.settings.update`, `course.delegation.create`, `course.delegation.revoke`; resource type `course`; policy `adm08.scoped.delegation`, version `adm08:v1`. + +A delegation is Active or Revoked. Creation version is 1; revocation increments it. Concurrent writes compare the captured active authority under the same simulator lock as the mutation. An account is checked again on every request; no capability is trusted from cookie claims, headers, query or request body. + +Architecture and DFD: `diagrams/adm08-architecture.svg` and `diagrams/adm08-dfd.svg`. Database persistence, root provisioning, real identity integration and provider contract acceptance remain planned. diff --git a/docs/components/coursework/BACKLOG.md b/docs/components/coursework/BACKLOG.md new file mode 100644 index 0000000..fda6d3e --- /dev/null +++ b/docs/components/coursework/BACKLOG.md @@ -0,0 +1,38 @@ +# Ordered backlog — ACC-04 and ADM-08 + +These are prepared tasks derived from specification sections 4.2 and 4.12, not a claim that Tapiz cards already exist. `IMPLEMENTED_LOCAL` concerns code; `EXTERNAL_PENDING` concerns real review/publication. Add actual task IDs, links, assignees and dates in Tapiz. Do not invent historical start/completion dates. + +| Order | Task | Phase | Implementation | Prerequisites / use cases | Acceptance evidence | +|---|---|---|---|---|---| +| 1 | ACC04-01 — Read own attempt and reject ID substitution | KT1 | IMPLEMENTED_LOCAL | Trusted account, membership and ATT snapshots; shared decision engine; Read own attempt; Reject foreign and missing IDs | AttemptPolicy.cs; ConcreteReadChecksOwnershipMembershipAndTeachingRole | +| 2 | ACC04-02 — Update own draft with validation and atomic version check | KT1 | IMPLEMENTED_LOCAL | ACC04-01; ATT snapshot/CAS contract; Update draft; Preserve submitted/completed history | AttemptService.cs; ConcurrentDraftWritesWithSameVersionHaveExactlyOneWinner | +| 3 | ACC04-03 — Verify teaching scope and fail closed on provider outage | KT1 | IMPLEMENTED_LOCAL | ACC04-01; positive/negative provider doubles; Teacher reads submitted own-course attempt; Reject blocked account/outage | DependencyOutageDeniesOwnerReadAndWriteWithoutMutation | +| 4 | ADM08-01 — Model two courses with separate administrative and teaching authority | KT1 | IMPLEMENTED_LOCAL | Current account provider; seeded explicit course root grants; shared engine; Read authorized course administration; Separate powers | AdministrationPolicy.cs; AdministrativeGrantDoesNotCreateTeachingMembership | +| 5 | ADM08-02 — Delegate limited course administration and update settings | KT1 | IMPLEMENTED_LOCAL | ADM08-01; whitelist and active target checks; Grant view/settings capability; Delegate changes course display name | AdministrationService.cs; DelegationRenameRevokeIsScopedAndImmediatelyEffective | +| 6 | ADM08-03 — Revoke delegation and reject reused authority | KT1 | IMPLEMENTED_LOCAL | ADM08-02; atomic grant snapshot/version check; Revoke active grant; Recheck existing session | RevocationRejectsPreviouslyCapturedAuthorityAtCommitBoundary | +| 7 | KT1-01 — Prepare domain model, matrix, architecture and DFD for both components | KT1 | PREPARED_LOCAL | Current implementation and selected specification sections; Explain components, data and trust boundaries | DOMAIN_MODEL.md; diagrams/ | +| 8 | KT1-02 — Threat model, repeatable seed and metadata-only change audit | KT1 | IMPLEMENTED_LOCAL | ACC04-02; ADM08-03; Reset synthetic state; Audit authorized changes and denials | THREAT_MODEL.md; DemoStore.cs | +| 9 | KT1-03 — Review at least one provider contract with the actual neighboring team | KT1 | EXTERNAL_PENDING | Prepared CONTRACTS.md and real provider owner; Agree ATT snapshot/CAS or identity boundary | CONTRACTS.md | +| 10 | KT1-04 — Record actual Tapiz task to branch to PR and green CI | KT1 | EXTERNAL_PENDING | Create actual cards; clean main-based branch; commit and push; Publish own contribution; Confirm actual remote checks | EVIDENCE.json; VALIDATION.md | +| 11 | KT1-05 — Demonstrate both flows and automated negative cases | KT1 | IMPLEMENTED_LOCAL | Start AccessKt1 profile; run current code tests; Run HTTP and React demo | scripts/kt1-demo.ps1; DEMO.md | +| 12 | ACC04-04 — Integrate real identity, membership and ATT providers | KT2 | PLANNED | KT1-03 accepted contracts; Use production authenticated subject and ATT storage | CONTRACTS.md | +| 13 | ADM08-04 — Persist grants and course metadata with durable audit | KT2 | PLANNED | ADM08-03; agreed root recovery/course metadata contracts; Provision roots safely; Persist delegation lifecycle | CONTRACTS.md | + +## Real Tapiz workflow + +1. Use the existing team board. Suggested groups are KT1 — Ownership, KT1 — Course administration, KT1 — Model/evidence, and KT2 — Integration. Actual sprint dates must come from your course timetable. +2. List prepared cards with `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/prepare-access-task.ps1 -List`. +3. Copy a title using `-TaskId ACC04-01 -TitleOnly`; paste as the real card title. Run without `-TitleOnly` and paste the full description. `-NoClipboard` prints text instead. +4. Set the actual assignee, prerequisites, status and current dates. Link the actual new branch and PR. Implemented code can be described as existing work; do not pretend the task existed before the recorded creation date. +5. Keep the contract-review and new remote-CI cards open until their actual evidence exists. Record the new PR link with the optional `-PrUrl` argument when preparing card text. + +The helper creates no remote card, sends no message and does not certify review or CI. A Story card is acceptable if that is how your board models an implementable use case; the content, links and verifiable contribution matter more than a label. + +## Source-to-use-case mapping + +| Assigned requirement | Specification source | Local use cases and platform relationship | Order / provider boundary | +|---|---|---|---| +| ACC-04 | §4.2; authorization rules §2.1 | Read concrete own resource; reject ID substitution; authorize draft write. These checks support UC-01/UC-02 and the scoped read portion of UC-04; full start/submission/evaluation flows are owned elsewhere and are not claimed | ACC04-01 → ACC04-02 → ACC04-03; Identity, membership and ATT ports | +| ADM-08 | §4.12; separation of roles in §2 | View authorized course administration; delegate limited administration; update course metadata; revoke administrative delegation. These are derived ADM-08 use cases; they do not claim UC-05 account/session revocation | ADM08-01 → ADM08-02 → ADM08-03; current account and administrative store ports | + +For actual card/PR/review recording commands see `CLOSURE.md` and `scripts/record-kt1-evidence.ps1`. Every student's real contribution must be linked; no remote evidence is inferred from this backlog. diff --git a/docs/components/coursework/CLOSURE.md b/docs/components/coursework/CLOSURE.md new file mode 100644 index 0000000..1cacd39 --- /dev/null +++ b/docs/components/coursework/CLOSURE.md @@ -0,0 +1,51 @@ +# Remaining external KT1 evidence + +The code fixes and diagrams are delivered. A saved URL is a reference, not automatic verification that the external requirement is satisfied. This checklist must be completed using actual work, authors and results. No CONTRIBUTION.md is required. + +## 1. Actual Tapiz cards + +Use the actual team board and create/update the prepared cards. The helper only copies text; it does not create remote cards. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\prepare-access-task.ps1 -List +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\prepare-access-task.ps1 -TaskId ACC04-01 -TitleOnly +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\prepare-access-task.ps1 -TaskId ACC04-01 +``` + +Paste title and description into the actual card, assign the actual student and current work status, and record real dates. Repeat for each applicable prepared task, including ADM-08 and external dependencies. Record each created card with the command below, replacing both sample values with real values before running: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\record-kt1-evidence.ps1 -TaskId ACC04-01 -TapizId "STVARNI_ID_KARTICE" -TapizUrl "STVARNI_HTTPS_LINK_KARTICE" +``` + +This updates `backlog.json` and `EVIDENCE.json`. Do not backdate task creation or claim that prepared files created remote cards. + +## 2. Own contribution and the new PR + +Use the actual main-based branch. Each student must show a concrete contribution and a commit, PR or other verifiable record. Link the actual Tapiz card to that work. Do not list authors who did not contribute. The original delivered source is not evidence of each student's own commit. + +Record the actual new PR and each student's evidence with the helper's `-PrUrl` and `-ContributorName` / `-ContributionUrl` parameters. See `PR-OPIS.md` outside the repository for the prepared PR description. Record your team's confirmed ACC-04 / ADM-08 assignment in the shared assignment table only with the actual team identifier; do not overwrite the existing Team 3 row. + +## 3. Actual GitHub Actions result + +Open the new PR, inspect the backend/frontend checks, the test log and the `acc04-adm08-kt1-results` artifact. Record the full SHA tested by that run, not an assumed local SHA. Use `-CiRunUrl` with `-TestedCommit`. Recording a URL deliberately does not set `ciArtifactConfirmed=true`; confirm the real result/artifact before declaring this obligation closed. + +A documentation-only evidence commit may lead to another run; the PR's current required checks still need to pass. Local reports must not be presented as GitHub Actions reports. + +## 4. Actual neighboring-team review + +`CONTRACTS.md` contains a concrete ATT snapshot/CAS proposal and review message. Send it yourself to the actual ATT provider owner, or agree an identity/shared-access boundary with its actual owning team. The neighbor needs to review the contract, not implement their entire module for you. + +Record a real acknowledgement with `-Reviewer`, `-ContractReviewUrl` and `-ReviewDate`. The review should identify the contract/version and reviewed code, state positive/negative/unavailable behavior, name who owns the real adapter, and list unresolved questions. A message asking for review is not an acknowledgement. + +## 5. Inspect recorded evidence + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\record-kt1-evidence.ps1 -Status +``` + +The status command reports recorded references only. It does not silently create cards, send messages, certify an approval or turn unverified CI into success. Once references have been checked, update the human-readable review/validation record and commit the documentation to the same PR. + +## Scope reminders + +The simulator is permitted for KT1. No production database, complete identity system, teaching-role assignment or evaluator implementation is claimed. Permanent-until-revoked administrative delegation is ADM-08; it does not implement temporary teaching permissions or contextual access windows. The shared `Platform.Access` component remains owned by its existing team. diff --git a/docs/components/coursework/CONTRACTS.md b/docs/components/coursework/CONTRACTS.md new file mode 100644 index 0000000..a65a106 --- /dev/null +++ b/docs/components/coursework/CONTRACTS.md @@ -0,0 +1,37 @@ +# Neighboring-module contracts and review record + +**Review status: PENDING — no neighboring-team approval is fabricated.** Record at least one actual review before claiming the full external KT1 obligation is closed. + +| Boundary | Provider / consumer ownership | Positive response | Negative / unavailable response | Proof | +|---|---|---|---|---| +| `Platform.Access.Contracts.IAccessDecision.DecideAsync` | Existing shared access component → both consumers | Explicit Allow with policy ID/version | Deny for unknown action, missing/blocked subject, absent/failed policy | `SharedDefaultDenyContractHandlesUnknownActionAndNoSubject`; HTTP uses same engine | +| `IAccountReader.FindAccount` | Identity provider → both consumers | Current matching active account | NotFound, inactive, Unavailable; mismatched identity must not grant access | HTTP blocked/current-account/outage checks | +| `IMembershipReader.FindMembership` | Membership provider → ACC-04 | Active matching account + course + role | Missing, inactive, mismatched or Unavailable | Cross-course teacher and membership-outage tests | +| `IAttemptStore.FindAttempt` | ATT metadata → ACC-04 | Immutable metadata snapshot without answer | Missing, Unavailable | `ProviderContractSupportsFoundMissingAndUnavailableWithoutAnswerInMetadata` | +| `IAttemptStore.ReadAnswer/UpdateDraft` | ATT content/simulator → ACC-04 | Answer bound to the exact authorized snapshot; atomic draft CAS | Snapshot mismatch denies; concurrent/stale write Conflict; Unavailable denies | `ConcurrentDraftWritesWithSameVersionHaveExactlyOneWinner` | +| `ICourseAdministrationStore` | ADM-08 → shared policy and administration service | Course metadata/current explicit grant; snapshot-bound read and authorized mutation | Missing, revoked, wrong-course, Conflict, Unavailable | HTTP delegation lifecycle and commit-boundary revocation test | +| `ISecurityAudit.Write` | Application change metadata → audit adapter | Metadata-only SecurityEvent | Simulator has no durable sink; production failure/transaction policy must be agreed | Draft audit test; terminal `KT1_AUDIT_JSON` | + +The existing shared contract is published in `docs/components/ACC-05-ACC-08.md`; that file and `backend/Platform.Access` remain unchanged. Concrete actions use lowercase dotted IDs accepted by its catalog. Module policies are registered into its existing registry; there is no alternate default-deny implementation. + +For KT1, `DemoStore` is a deterministic test double implementing positive, missing and unavailable responses. No caller can toggle outage modes via HTTP. The course administration simulator owns only administrative metadata and grants. It never writes to another module's membership, teaching assignment, student answer ownership or grade tables. + +## Proposed ATT review message + +Please review the ACC-04 boundary in `Modules/Access/Contracts.cs`: `FindAttempt` returns metadata without Answer; the content operation receives the exact authorized snapshot; `UpdateDraft` atomically checks that snapshot, InProgress state and expected version. Found / NotFound / Unavailable must stay distinct internally; unauthorized public responses stay identical. Confirm ownership of the real adapter and whether this CAS contract fits your ATT module. This is a review request, not a claim that your module is already implemented. + +## Record after a real review + +- Neighboring team/module and reviewer: PENDING. +- Actual reviewed commit or PR and contract version: PENDING. +- Date of the real review: PENDING. +- Comment/issue/Tapiz URL containing acknowledgement: PENDING. +- Agreed changes and unresolved questions: PENDING. + +A request message alone is not an accepted contract. A local simulator alone does not prove external agreement. Real provider transactions must bind authorization-relevant metadata and writes to a consistent version; cross-provider revocation during an in-flight operation requires an agreed integration strategy. KT1 simulator checks administrative revocation atomically in its own store. + +## Concrete review packet — proposed ATT contract v1 + +Review `Modules/Access/Contracts.cs` together with `AttemptService` and the simulator: metadata contains Id, OwnerId, CourseId, State and Version, not the answer. An authorized read is bound to that exact snapshot. A draft mutation checks InProgress state, the same snapshot and expected version atomically; exactly one concurrent same-version write succeeds. Provider replies distinguish found/missing/unavailable internally while unauthorized public replies are normalized. The client never supplies authoritative ownership or membership. + +Ask the actual provider owner to confirm: ownership of the real adapter; version/snapshot binding; treatment of transient failure; historical-state protection; and current-identity/revocation consistency at integration. The delivered test double and contract tests can be reviewed without implementing the neighboring module. Record the actual acknowledgement and its reviewed commit/version. See `CLOSURE.md` for evidence commands. diff --git a/docs/components/coursework/DEMO.md b/docs/components/coursework/DEMO.md new file mode 100644 index 0000000..80ff19e --- /dev/null +++ b/docs/components/coursework/DEMO.md @@ -0,0 +1,48 @@ +# KT1 demonstration + +Requirements: .NET SDK 10; Node 22.12+ or 24+; Windows PowerShell 5.1 or PowerShell 7. Docker and Tapiz are not required to run this local simulator. + +## Terminal 1 — API + +```powershell +cd "$env:USERPROFILE\Desktop\security-lab-acc04-adm08" +dotnet run --project ".\backend\Platform.Api\Platform.Api.csproj" --launch-profile AccessKt1 +``` + +Leave it running. API: `http://localhost:5080`. Restart this process to restore the deterministic seed and root grants. The `AccessKt1` launch profile explicitly enables synthetic sessions in Development. Without both conditions, `/api/kt1/*` is absent. + +## Terminal 2 — actual HTTP demonstration + +```powershell +cd "$env:USERPROFILE\Desktop\security-lab-acc04-adm08" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\kt1-demo.ps1" +``` + +The script uses real HTTP calls and cookies. It stops on the first unexpected result, clears a previous evidence file before starting, and writes completed evidence only after every scenario passes. It reads the current draft/course versions so the successful flow can be rerun. A failed run after creating a grant may require an API restart before rerunning. + +Show: owner read + draft update + persisted read; foreign ID rejected; stale write rejected; submitted/completed history immutable; teacher scoped to submitted course attempts; admin has no answer bypass; course A/B isolation; limited delegation; delegate settings update; no redelegation; cross-course revoke denied; root revoke; existing delegate cookie immediately denied. Administrator and delegate use separate HTTP clients; the delegate signs in exactly once before revocation, and the script checks that its cookie did not change. The JSON proof contains booleans/counters, never the cookie itself. + +Evidence: `TestResults/acc04-adm08-http-demo.json`. Structured change audit appears in the API terminal as `KT1_AUDIT_JSON` and contains metadata, not answers. + +## Terminal 3 — React frontend + +```powershell +cd "$env:USERPROFILE\Desktop\security-lab-acc04-adm08\frontend" +npm.cmd ci +npm.cmd run dev +``` + +Open `http://localhost:5173`. Keep this terminal running; stop Vite with Ctrl+C before a later `npm ci` on Windows to avoid locked native module files. + +Choose account and **Sign in**. ACC-04: select Ana's draft, **Read attempt**, edit, **Save own draft**; switch to Marko and repeat the read. ADM-08: sign in as admin, **Load administration**, **Create delegation**. Keep the grant ID/version shown in the fields, sign in as delegate and change course A settings; try B and an attempt. Sign back in as admin and **Revoke delegation** using the retained ID/version, then repeat the delegate request. The server enforces every rule even if a user calls the API directly. + +## Tests and frontend build (stop Vite first) + +```powershell +cd "$env:USERPROFILE\Desktop\security-lab-acc04-adm08" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\check-kt1.ps1" +``` + +This runs `dotnet test backend/SecurityLab.slnx` and the frontend install/build. It does not auto-start the live demo, create Tapiz cards or claim a remote Actions result. See `VALIDATION.md` for the actual packaged validation. + +For actual Tapiz, contribution, PR/CI and neighboring-review references, follow `CLOSURE.md`. The `record-kt1-evidence.ps1` helper records user-supplied references without fabricating external completion. diff --git a/docs/components/coursework/DOMAIN_MODEL.md b/docs/components/coursework/DOMAIN_MODEL.md new file mode 100644 index 0000000..a03e61d --- /dev/null +++ b/docs/components/coursework/DOMAIN_MODEL.md @@ -0,0 +1,44 @@ +# Unified domain and access matrix + +| Entity / snapshot | Key fields and states | Data owner | +|---|---|---| +| AccountSnapshot | Id, IsActive | Identity provider; synthetic fixture for KT1 | +| MembershipSnapshot | AccountId, CourseId, Student/Teacher/Assistant, IsActive | Course membership provider | +| AttemptSnapshot | Id, OwnerId, CourseId, InProgress/Submitted/Completed, Version | ATT provider; ownership is never a client parameter | +| AttemptView | Id, Answer, Version | ATT content returned only after authorization | +| CourseSnapshot | Id, DisplayName, Version | Administrative metadata adapter; actual course contract must be reviewed | +| AdminGrant | Id, CourseId, GranteeId, GrantedBy, Capabilities, Active/Revoked, Version, IsRoot | ADM-08; no membership/teaching-role assignment | +| SecurityEvent | Time, correlation, actor, action, target, course, outcome, reason, policy version, before/after versions | Metadata-only audit; bounded process-local simulator | + +Attempt transitions are consumed from ATT. This contribution modifies only an InProgress answer and advances Version; it does not expose Submit or Complete. Root admin grants are provisioned only by the deterministic seed. A regular administrative grant can transition from absent → Active → Revoked. Regrant creates a new grant ID. Revoked authority cannot be reused for a read or write. + +## Subject–Action–Resource–Allow/Deny + +All Allow rows require a current active account, a valid resource snapshot and all applicable course checks. Any missing/invalid state or provider failure denies. + +| Subject | Action | Resource / condition | Decision | +|---|---|---|---| +| Anonymous | Any protected operation | Any | Deny / 401 | +| Active Student owner + active same-course membership | attempt.read | Own attempt | Allow | +| Other student | attempt.read / attempt.draft.update | Another owner's attempt | Deny / 404 | +| Same-course Teacher | attempt.read | Submitted or Completed attempt | Allow | +| Same-course Teacher | attempt.read | InProgress attempt of a student | Deny / 404 | +| Teacher from another course / Assistant without review authority | attempt.read | Student attempt | Deny / 404 | +| Active Student owner | attempt.draft.update | InProgress + valid input + matching version | Allow; Version + 1 | +| Owner | attempt.draft.update | Submitted / Completed | Deny / 404 | +| Authorized owner | attempt.draft.update | Stale version | Deny write / 409 | +| Root administrator in course A | course.admin.read / settings.update | Course A | Allow | +| Root administrator in course A | Administrative actions | Course B | Deny / 404 | +| Root administrator in course A | delegation.create / revoke | Active non-root grant in A; allowed target/capabilities/version | Allow | +| Active delegate with ViewAdministration | course.admin.read | Granted course only | Allow | +| Active delegate with ManageSettings | course.settings.update | Granted course + matching course version | Allow | +| View-only delegate | course.settings.update | Any course | Deny / 404 | +| Any delegate | delegation.create / revoke | No root delegation power | Deny / 404 | +| Revoked delegate / blocked account | Administrative actions | Any course | Deny / 404 | +| Administrator / delegate without teaching membership | attempt.read / update | Student answer | Deny / 404 | +| Teacher without administrative grant | Administrative actions | Even own teaching course | Deny / 404 | +| Any caller | Unknown action | Any | Shared engine Deny | + +Input validation returns 400 for syntactically invalid requests, independently of existence. An authorized caller can receive 409 for a version/active-grant conflict. Foreign/missing resources share exactly `{"code":"RESOURCE_UNAVAILABLE"}` with HTTP 404, including upstream outages. Internal event reasons are not included in these HTTP responses. This is response-shape protection, not a constant-time execution guarantee. + +See `diagrams/access-admin-domain.svg` and `access-admin-flow.svg`. diff --git a/docs/components/coursework/EVIDENCE.json b/docs/components/coursework/EVIDENCE.json new file mode 100644 index 0000000..0f5e23a --- /dev/null +++ b/docs/components/coursework/EVIDENCE.json @@ -0,0 +1,49 @@ +{ + "requirements": [ + "ACC-04", + "ADM-08" + ], + "baseCommit": "0d2d5ced289d7b23abca4b1ad35895d1cb3b9057", + "localValidation": { + "date": "2026-11-01", + "environment": "Linux / .NET 10.0.403 / PowerShell 7.6.9", + "nunit": { + "total": 60, + "passed": 60, + "failed": 0, + "skipped": 0 + }, + "liveHttpDemo": { + "total": 41, + "passed": 41, + "revocationProof": { + "separateClients": true, + "samePreRevocationCookie": true, + "delegateSignIns": 1 + } + }, + "frontendBuild": "PASS", + "browser": "PASS", + "revision": "Corrected old-session regression and DFD return/denial data flows; actual test reports regenerated.", + "browserProof": { + "passed": true, + "separateBrowserContexts": true, + "delegateSignIns": 1, + "samePreRevocationCookie": true, + "readAfterRevoke": 404, + "writeAfterRevoke": 404, + "viewportWidth": 420 + } + }, + "externalEvidence": { + "newPrUrl": null, + "testedCommit": null, + "ciRunUrl": null, + "ciArtifactConfirmed": false, + "tapizTaskLinks": [], + "neighborContractReview": null, + "contributors": [], + "referenceStatus": "PENDING_REAL_EXTERNAL_EVIDENCE" + }, + "note": "No remote task, external acceptance, student commit, or GitHub Actions success is fabricated. Complete external references after actual actions." +} diff --git a/docs/components/coursework/KT1.md b/docs/components/coursework/KT1.md new file mode 100644 index 0000000..374823d --- /dev/null +++ b/docs/components/coursework/KT1.md @@ -0,0 +1,49 @@ +# KT1 — ACC-04 and ADM-08 + +**Assigned scope:** ACC-04 (J), concrete-resource authorization; ADM-08 (N), multi-course administration with delegation. The authoritative requirement IDs and minimum acceptance criteria are in `docs/spec/PROJECT_SPECIFICATION_OIB.pdf`, sections 4.2 and 4.12. Legacy Scutum requirement IDs do not identify this assignment. + +This is a started, demonstrable implementation with synthetic HTTP sessions and a repeatable in-memory simulator. It is not a production authentication system or a completed implementation of every platform module. The specification requires an ACC-04 student to be unable to read another student's attempt by direct URL; for ADM-08 it requires verifiable separation of administrative and teaching power. Both boundaries are implemented and tested here. + +## Five presentation requirements + +| Requirement | Concrete evidence | Demonstration / limitation | +|---|---|---| +| Started implementation | `backend/Platform.Api/Modules/Access`, `Administration`, `Kt1`; `frontend/src/components/CourseworkKt1.tsx` | Launch profile `AccessKt1`; two ACC-04 operations and an ADM-08 delegation lifecycle | +| Own contribution | New task-linked branch, the student's actual commit and PR | Run `git log --oneline` and show the PR diff. No invented authors, backdated work, or separate contribution file | +| Architecture of each assigned component | `diagrams/acc04-architecture.mmd/.svg`, `adm08-architecture.mmd/.svg` | Components, responsibilities, public interfaces, shared decision engine and provider boundaries | +| DFD of each assigned component | `diagrams/acc04-dfd.mmd/.svg`, `adm08-dfd.mmd/.svg` | Actors, input/output, processes, stores and client/server/provider trust boundaries | +| Short demonstration | `scripts/kt1-demo.ps1`, React demo, `DEMO.md` | Own draft read/write, IDOR denial, scoped administration, delegation, settings update and immediate revocation | + +## All mandatory obligations + +| Obligation | Repository evidence | What still requires real external action | +|---|---|---| +| Use cases, order and prerequisites for every assigned requirement in Tapiz | `BACKLOG.md`, `backlog.json`, `scripts/prepare-access-task.ps1` | Create actual cards, record their IDs/URLs, assign actual members and dates. Prepared descriptions do not create cards | +| Unified model, states, ownership and Subject–Action–Resource matrix | `DOMAIN_MODEL.md`, domain and flow diagrams | Review with the actual neighboring providers | +| Structure, startup README, successful build, initial CI, task → branch → PR | Root README; `.github/workflows/ci.yml`; `VALIDATION.md` | Push this new branch, open a new PR, inspect its actual CI. Never reuse old PR/test counts as new evidence | +| Complete primary flow through application logic and simulator; two operations | `AttemptService.Execute`: read and update own draft; snapshot-bound storage and CAS | Persistent ATT adapter is KT2 work | +| Server authorization, validation, automated negative test | Shared `IAccessDecision` + `AttemptPolicy` / `AdministrationPolicy`; `Platform.Kt1.Tests` | Production identity/membership integration | +| Threat model for both assigned requirements | `THREAT_MODEL.md`: asset → misuse → control → named test → evidence | Deployment and provider-specific threats must be revisited at integration | +| Repeatable state and structured secret-free change audit | `DemoStore`; deterministic IDs; restart-to-reset; `SecurityEvent` / `KT1_AUDIT_JSON` | Durable database migration and transactional audit adapter in KT2 | +| At least one reviewed neighboring-team contract; test double with positive and negative replies | `CONTRACTS.md`, current shared access interfaces, found/missing/unavailable provider tests | Actual neighboring-team acknowledgement is **PENDING**; attach its real review reference | + +The code/documentation portion is prepared. Full administrative closure of KT1 also needs the real Tapiz trace, actual PR/CI evidence and at least one actual contract review. A local ZIP cannot certify those actions. + +## Relationship to shared platform code + +The existing `backend/Platform.Access` project and its shared team's documentation are preserved unchanged. This contribution registers six concrete actions and two policies through the existing catalog/registry and calls the published `IAccessDecision.DecideAsync` contract. It does not introduce another decision engine. The original shared `/api/access/demo-deny` route remains available in Development. + +The student's former contextual/time-window feature is absent from the new owned code, fixtures, tests and diagrams. ADM-08 uses explicit course administrative grants, not teaching roles or a generic global-admin bypass. Shared specification catalogs and other teams' existing components are not rewritten. + +## Remaining work through KT2 (exercise 8) + +1. Record real Tapiz cards and review the ATT snapshot/CAS and identity boundaries with the assigned neighboring teams. +2. Integrate actual authenticated account/membership and ATT providers, with current shared decision contracts maintained. +3. Replace the simulator with course/grant persistence, migrations, optimistic concurrency and durable audit in the same transaction. +4. Agree provisioning/recovery of root course administrators; the KT1 seed is not a root-role management API. +5. Integrate the real administrative UI and the agreed course metadata contract. Teaching assignment, grades, account provisioning and course creation remain with their owners. +6. Re-run cross-module integration and deployment tests and attach real CI evidence. + +## Detailed-review corrections + +The previously weak existing-session proof is corrected: the HTTP lifecycle uses separate clients, and two dedicated read/write regression cases explicitly reuse the signed cookie captured before revocation with automatic cookie handling disabled. The live demo also preserves its separate delegate client and checks one sign-in and the same cookie after revocation. Both component DFDs now show provider/store responses and denial paths, including metadata-only auditing. External closure instructions and a reference-recording helper are in `CLOSURE.md`; those cannot substitute for actual remote cards, approvals, contributions or successful CI. diff --git a/docs/components/coursework/THREAT_MODEL.md b/docs/components/coursework/THREAT_MODEL.md new file mode 100644 index 0000000..fbfbbf5 --- /dev/null +++ b/docs/components/coursework/THREAT_MODEL.md @@ -0,0 +1,23 @@ +# Threat model — both assigned components + +Trust boundaries: (1) untrusted browser ↔ server API; (2) application ↔ shared decision engine and trusted identity/membership/ATT adapters; (3) authorized application ↔ data mutation and audit adapter. The KT1 simulator runs providers in one process but preserves their interface boundaries. Its cookie is a synthetic session reference, not authoritative role/membership/capability data. + +| Component / asset | Misuse or threat | Implemented control | Automated test | Evidence | +|---|---|---|---|---| +| ACC-04 student answer | Replace URL ID to read another owner | Current ownership + course membership + shared explicit decision; content fetched after authorization | `ConcreteReadChecksOwnershipMembershipAndTeachingRole`, `ForgedActorHeadersDoNotElevateStudent` | NUnit TRX; HTTP IDOR demo | +| ACC-04 answer existence | Compare foreign and missing results, including outage | Identical public 404 JSON; metadata-only internal reason | `ForeignAndMissingAreIdenticalEvenWithMembershipOutage` | NUnit HTTP tests | +| ACC-04 unsubmitted answer | Teacher/assistant/admin uses broad role | Teacher only same-course submitted/completed reads; no admin bypass | `UnsubmittedAnswerStaysPrivateFromTeacher`, read matrix tests | NUnit + demo | +| ACC-04 draft/history | Concurrent overwrite or edit historical state | Input validation + owner student + InProgress + atomic snapshot/version CAS | `ConcurrentDraftWritesWithSameVersionHaveExactlyOneWinner`, `SubmittedAndCompletedAttemptsAreImmutable` | NUnit + demo | +| ACC-04 decision | Stale/inactive identity or failed dependency becomes Allow | Current account and membership checks; fail closed | `DependencyOutageDeniesOwnerReadAndWriteWithoutMutation` | NUnit | +| ADM-08 course isolation | Course A administrator changes B | Explicit grant bound to course; no global role shortcut | `RootIsCourseScopedAndCannotRevokeOtherCourseOrRootGrant` | NUnit + demo | +| ADM-08 delegation | Self-escalation, arbitrary capability, teaching grant or redelegation | Exact capability whitelist, no self-grant, only roots delegate/revoke | `InvalidOrEscalatingCapabilitySetIsRejected`, delegation lifecycle | NUnit + demo | +| ADM-08 authority | Reuse revoked grant or existing cookie | Current grant each request; captured authority/version rechecked under mutation lock | `RevocationRejectsTheSamePreviouslyIssuedCookie`, `RevocationRejectsPreviouslyCapturedAuthorityAtCommitBoundary` | NUnit + immediate-revoke demo | +| ADM-08 separation of powers | Administrative grant becomes teaching membership | Separate stores and capabilities; no answer or membership mutation in admin APIs | `AdministrativeGrantDoesNotCreateTeachingMembership`, admin answer denials | NUnit + demo | +| ADM-08 provider availability | Missing/failed admin dependency reveals course or grants access | Public generic denial, no mutation | `AdminOutageIsFailClosedAndDoesNotRevealMissingCourse` | NUnit | +| Both components / session | Client supplies actor/role, cross-site mutation or production demo exposure | Server-issued HttpOnly Strict cookie; no CORS; mandatory non-simple mutation header; Development + explicit flag | `ForgedActorHeadersDoNotElevateStudent`, `MissingMutationHeaderCannotChangeSessionOrDraft`, `SyntheticRoutesRequireDevelopmentAndExplicitFlag` | NUnit | +| Both components / audit | Answer/secret leaks into change log | Fixed structured SecurityEvent fields; no answer, cookie, token or credentials | `DraftUpdatePersistsAndStaleVersionFailsWithMetadataOnlyAudit` | NUnit + server terminal | +| Shared platform | Unknown action receives implicit access | Existing shared default-deny decision/catalog/registry unchanged | `SharedDefaultDenyContractHandlesUnknownActionAndNoSubject` | NUnit; existing shared demo preserved | + +Residual limitations: simulator data and audit are process-local and reset on restart; no production credentials are accepted; all demo routes are off unless explicitly enabled in Development. HTTP 404 normalization does not claim timing equivalence. Database-level audit atomicity, cross-module transactions, production session security, operational monitoring and actual provider review remain KT2 integration work. No production-completeness claim is made. + +The HTTP revocation regression now explicitly sends the cookie issued before revocation, using separate administrator and delegate clients and no second delegate login. The live script verifies exactly one delegate sign-in and an unchanged cookie, records only proof booleans/counters, and never stores the cookie value in evidence. Both DFDs include provider/store return data and explicit denial/audit routes. diff --git a/docs/components/coursework/VALIDATION.md b/docs/components/coursework/VALIDATION.md new file mode 100644 index 0000000..1c803c7 --- /dev/null +++ b/docs/components/coursework/VALIDATION.md @@ -0,0 +1,36 @@ +# Actual local validation — ACC-04 and ADM-08 + +Package validation date: **2026-11-01**. Source basis: `main` commit `0d2d5ced289d7b23abca4b1ad35895d1cb3b9057`. Results below concern this new ACC-04 + ADM-08 source package, not earlier ACC-04/context-window PRs. + +| Check actually executed | Result | Evidence | +|---|---|---| +| .NET 10 Release build + NUnit unit/HTTP/contract tests | **60/60 passed; 0 failed, 0 skipped** | `verification/acc04-adm08-kt1.trx` in the delivery ZIP; regenerated under `TestResults/` locally | +| PowerShell script against a running real Kestrel API | **41/41 passed** | `verification/acc04-adm08-http-demo.json`, `powershell-demo.log` | +| Frontend clean install and TypeScript/Vite build | **PASS** | `verification/frontend-build.log` | +| Actual Chromium browser | **PASS** | Owned draft update, identity switch clears displayed content, IDOR denial, course delegation, settings update, UI revocation in independent browser contexts, unchanged pre-revocation delegate cookie, exactly one delegate sign-in, read/write denial; 420px layout without horizontal overflow | +| Diagram rendering | Six Mermaid sources rendered as six SVGs; visual inspection passed | `diagrams/*.mmd` and `*.svg` | +| Safe worktree installer | PASS on Linux PowerShell: paths with spaces, exact Git index tree, actual original staged/unstaged/untracked work preserved, repeat install refuses overwrite | `verification/installer-validation.txt` | + +The runtime used for local verification is Linux with .NET SDK 10.0.403 and PowerShell 7.6.9. Windows PowerShell 5.1-compatible syntax is used for the install/demo/task scripts, but this package was not executed on the student's Windows PC. Run the supplied Windows commands and retain that machine's actual outputs. + +The actual HTTP demo checks: anonymous read/write, owner read/write, persisted answer, stale update, immutable submitted/completed history, invalid input, missing mutation header, foreign ID/write, teacher scope and draft privacy, blocked account, assistant denial, admin with no answer bypass, isolated A/B administration, whitelist/self-grant/duplicate constraints, scoped delegation, delegate settings update, stale course version, no cross-course access or redelegation, cross-course/stale revocation, successful revoke and immediate denial of the delegate's existing cookie. + +The automated suite also verifies provider outages, snapshot-bound reads/CAS, exactly one winner among concurrent draft updates, administrative revocation at the final storage boundary, secret-free audit, production/disabled-demo route absence and compatibility with the existing shared default-deny contract. + +## Remote evidence + +- New PR: **PENDING** — create a new ACC-04 + ADM-08 PR from the main-based branch. +- New GitHub Actions run for this source: **PENDING** — not executed or claimed before upload. +- Expected artifact name configured in CI: `acc04-adm08-kt1-results` — its remote existence must be confirmed from that actual run. +- Real Tapiz task/card IDs and links: **PENDING**. +- Actual neighboring-team contract acknowledgement: **PENDING**, see `CONTRACTS.md`. + +The GitHub workflow retains the existing backend and frontend jobs and adds tests, live demonstration and artifact upload to the backend job. It runs on pull requests and pushes to main. A local feature-branch push by itself is not proof of a run. Replace pending entries only after checking real URLs, the tested commit and the successful result. The packaged reports are a local snapshot, not a remote CI artifact or proof of the student's earlier authorship. + +## Results after the detailed-review corrections + +The two dedicated `RevocationRejectsTheSamePreviouslyIssuedCookie` cases cover read and write using an explicitly captured cookie with automatic cookie handling disabled. The lifecycle test also keeps independent admin/delegate clients. The live PowerShell script reports 41/41 and records `revocationProof` with separate clients, one delegate sign-in and the same cookie before/after revocation. Actual Chromium verification uses two browser contexts and confirms both read/write denial without a new delegate login. No cookie value is written into the proof JSON. + +Both DFDs were regenerated and visually inspected with provider/store return flows, explicit rejection paths and application audit. Transport rejections that occur before application processing are shown separately and are not falsely claimed to produce an application audit event. + +`record-kt1-evidence.ps1` parses and was exercised against a disposable synthetic fixture: missing required fields cause no writes, valid references are stored, and CI success is not inferred from a URL. Source external-evidence fields remain pending. PowerShell syntax checks and the fresh-install / prior-package update / no-overwrite checks are included in the delivery verification folder. Windows execution and a new remote Actions run remain unconfirmed until actually performed. diff --git a/docs/components/coursework/backlog.json b/docs/components/coursework/backlog.json new file mode 100644 index 0000000..9767cf3 --- /dev/null +++ b/docs/components/coursework/backlog.json @@ -0,0 +1,280 @@ +{ + "requirements": [ + "ACC-04", + "ADM-08" + ], + "note": "Prepared descriptions only. Actual Tapiz cards, dates, authorship and review are not created by this file.", + "tasks": [ + { + "id": "ACC04-01", + "phase": "KT1", + "implementation": "IMPLEMENTED_LOCAL", + "title": "Read own attempt and reject ID substitution", + "requirements": [ + "ACC-04" + ], + "useCases": [ + "Read own attempt", + "Reject foreign and missing IDs" + ], + "prerequisites": "Trusted account, membership and ATT snapshots; shared decision engine", + "acceptance": "Owner succeeds; foreign/missing share public 404; no client identity override", + "evidence": [ + "AttemptPolicy.cs", + "ConcreteReadChecksOwnershipMembershipAndTeachingRole" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "ACC04-02", + "phase": "KT1", + "implementation": "IMPLEMENTED_LOCAL", + "title": "Update own draft with validation and atomic version check", + "requirements": [ + "ACC-04" + ], + "useCases": [ + "Update draft", + "Preserve submitted/completed history" + ], + "prerequisites": "ACC04-01; ATT snapshot/CAS contract", + "acceptance": "Own InProgress draft persists; stale or concurrent write cannot overwrite; history stays immutable", + "evidence": [ + "AttemptService.cs", + "ConcurrentDraftWritesWithSameVersionHaveExactlyOneWinner" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "ACC04-03", + "phase": "KT1", + "implementation": "IMPLEMENTED_LOCAL", + "title": "Verify teaching scope and fail closed on provider outage", + "requirements": [ + "ACC-04" + ], + "useCases": [ + "Teacher reads submitted own-course attempt", + "Reject blocked account/outage" + ], + "prerequisites": "ACC04-01; positive/negative provider doubles", + "acceptance": "Teacher cannot read draft or other course; admin cannot bypass; outage denies with no mutation", + "evidence": [ + "DependencyOutageDeniesOwnerReadAndWriteWithoutMutation" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "ADM08-01", + "phase": "KT1", + "implementation": "IMPLEMENTED_LOCAL", + "title": "Model two courses with separate administrative and teaching authority", + "requirements": [ + "ADM-08" + ], + "useCases": [ + "Read authorized course administration", + "Separate powers" + ], + "prerequisites": "Current account provider; seeded explicit course root grants; shared engine", + "acceptance": "Admin A cannot administer B or read student answers; teacher lacks admin by default", + "evidence": [ + "AdministrationPolicy.cs", + "AdministrativeGrantDoesNotCreateTeachingMembership" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "ADM08-02", + "phase": "KT1", + "implementation": "IMPLEMENTED_LOCAL", + "title": "Delegate limited course administration and update settings", + "requirements": [ + "ADM-08" + ], + "useCases": [ + "Grant view/settings capability", + "Delegate changes course display name" + ], + "prerequisites": "ADM08-01; whitelist and active target checks", + "acceptance": "Only a same-course root delegates; no self-grant, teaching capability or redelegation", + "evidence": [ + "AdministrationService.cs", + "DelegationRenameRevokeIsScopedAndImmediatelyEffective" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "ADM08-03", + "phase": "KT1", + "implementation": "IMPLEMENTED_LOCAL", + "title": "Revoke delegation and reject reused authority", + "requirements": [ + "ADM-08" + ], + "useCases": [ + "Revoke active grant", + "Recheck existing session" + ], + "prerequisites": "ADM08-02; atomic grant snapshot/version check", + "acceptance": "Revocation is course-scoped. Independently authenticated delegate sends the exact cookie issued before revocation, without signing in again, and both read/write deny; captured grant cannot authorize final store operation.", + "evidence": [ + "RevocationRejectsTheSamePreviouslyIssuedCookie", + "RevocationRejectsPreviouslyCapturedAuthorityAtCommitBoundary", + "scripts/kt1-demo.ps1" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "KT1-01", + "phase": "KT1", + "implementation": "PREPARED_LOCAL", + "title": "Prepare domain model, matrix, architecture and DFD for both components", + "requirements": [ + "ACC-04", + "ADM-08" + ], + "useCases": [ + "Explain components, data and trust boundaries" + ], + "prerequisites": "Current implementation and selected specification sections", + "acceptance": "Two architecture diagrams and two DFDs agree with actual code; unified model and matrix present", + "evidence": [ + "DOMAIN_MODEL.md", + "diagrams/" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "KT1-02", + "phase": "KT1", + "implementation": "IMPLEMENTED_LOCAL", + "title": "Threat model, repeatable seed and metadata-only change audit", + "requirements": [ + "ACC-04", + "ADM-08" + ], + "useCases": [ + "Reset synthetic state", + "Audit authorized changes and denials" + ], + "prerequisites": "ACC04-02; ADM08-03", + "acceptance": "Restart restores seed; structured event excludes answers/cookies/secrets", + "evidence": [ + "THREAT_MODEL.md", + "DemoStore.cs" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "KT1-03", + "phase": "KT1", + "implementation": "EXTERNAL_PENDING", + "title": "Review at least one provider contract with the actual neighboring team", + "requirements": [ + "ACC-04", + "ADM-08" + ], + "useCases": [ + "Agree ATT snapshot/CAS or identity boundary" + ], + "prerequisites": "Prepared CONTRACTS.md and real provider owner", + "acceptance": "Attach acknowledgement URL, reviewer, actual date and reviewed commit; request alone is insufficient", + "evidence": [ + "CONTRACTS.md" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "KT1-04", + "phase": "KT1", + "implementation": "EXTERNAL_PENDING", + "title": "Record actual Tapiz task to branch to PR and green CI", + "requirements": [ + "ACC-04", + "ADM-08" + ], + "useCases": [ + "Publish own contribution", + "Confirm actual remote checks" + ], + "prerequisites": "Create actual cards; clean main-based branch; commit and push", + "acceptance": "Record actual IDs, branch, new PR URL, tested commit and successful Actions URL", + "evidence": [ + "EVIDENCE.json", + "VALIDATION.md" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "KT1-05", + "phase": "KT1", + "implementation": "IMPLEMENTED_LOCAL", + "title": "Demonstrate both flows and automated negative cases", + "requirements": [ + "ACC-04", + "ADM-08" + ], + "useCases": [ + "Run HTTP and React demo" + ], + "prerequisites": "Start AccessKt1 profile; run current code tests", + "acceptance": "All scripted scenarios pass; preserve actual reports, not expected counts", + "evidence": [ + "scripts/kt1-demo.ps1", + "DEMO.md" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "ACC04-04", + "phase": "KT2", + "implementation": "PLANNED", + "title": "Integrate real identity, membership and ATT providers", + "requirements": [ + "ACC-04" + ], + "useCases": [ + "Use production authenticated subject and ATT storage" + ], + "prerequisites": "KT1-03 accepted contracts", + "acceptance": "Cross-module authorization and CAS remain correct with real providers", + "evidence": [ + "CONTRACTS.md" + ], + "tapizId": null, + "tapizUrl": null + }, + { + "id": "ADM08-04", + "phase": "KT2", + "implementation": "PLANNED", + "title": "Persist grants and course metadata with durable audit", + "requirements": [ + "ADM-08" + ], + "useCases": [ + "Provision roots safely", + "Persist delegation lifecycle" + ], + "prerequisites": "ADM08-03; agreed root recovery/course metadata contracts", + "acceptance": "Migrations, grant uniqueness, concurrency and transaction-bound audit; real UI integration", + "evidence": [ + "CONTRACTS.md" + ], + "tapizId": null, + "tapizUrl": null + } + ] +} diff --git a/docs/components/coursework/diagrams/README.md b/docs/components/coursework/diagrams/README.md new file mode 100644 index 0000000..c13330d --- /dev/null +++ b/docs/components/coursework/diagrams/README.md @@ -0,0 +1,16 @@ +# KT1 diagrams + +Each `.mmd` is editable Mermaid source. The matching `.svg` is rendered and opens in a browser without Mermaid tools. + +| Diagram | Component / purpose | +|---|---| +| acc04-architecture | ACC-04 components, interfaces and dependencies | +| acc04-dfd | ACC-04 actors, processes, stores and trust boundaries | +| adm08-architecture | ADM-08 components, interfaces and dependencies | +| adm08-dfd | ADM-08 administrative data flow and trust boundaries | +| access-admin-domain | Unified entities, ownership and course scope | +| access-admin-flow | Shared decision and snapshot-bound read/write flow | + +Provider ports are trusted server contracts. In KT1 their adapters run in the in-memory simulator; separate boxes identify ownership and trust boundaries, not deployed microservices. The administrative store contains no student answers or teaching memberships. + +Malformed transport input (such as an invalid ID/body or a missing mutation header) can be rejected before application audit processing. The audit flows represent application outcomes and relevant changes; they do not claim to log every framework/parser rejection. diff --git a/docs/components/coursework/diagrams/acc04-architecture.mmd b/docs/components/coursework/diagrams/acc04-architecture.mmd new file mode 100644 index 0000000..9783b6f --- /dev/null +++ b/docs/components/coursework/diagrams/acc04-architecture.mmd @@ -0,0 +1,10 @@ +flowchart TD + UI["React attempt demo"] -->|HTTP / cookie / input| API["KT1 attempt endpoints"] + API --> SERVICE["AttemptService: read and draft update"] + SERVICE -->|AccessRequest| ENGINE["Shared IAccessDecision: default deny"] + ENGINE -->|Registered action policy| POLICY["AttemptPolicy: owner, course, state"] + POLICY --> PORTS["Account / membership / ATT snapshot ports"] + SERVICE -->|Authorized snapshot + expected version| STORE["IAttemptStore: read / atomic draft CAS"] + PORTS --> SIM["DemoStore: synthetic provider adapters"] + STORE --> SIM + SERVICE --> AUDIT["ISecurityAudit: metadata only"] diff --git a/docs/components/coursework/diagrams/acc04-architecture.svg b/docs/components/coursework/diagrams/acc04-architecture.svg new file mode 100644 index 0000000..51d251e --- /dev/null +++ b/docs/components/coursework/diagrams/acc04-architecture.svg @@ -0,0 +1 @@ +HTTP / cookie / inputAccessRequestRegistered action policyAuthorized snapshot +expected version

React attempt demo

KT1 attempt endpoints

AttemptService: read and draft update

Shared IAccessDecision: default deny

AttemptPolicy: owner, course, state

Account / membership / ATT snapshot ports

IAttemptStore: read / atomic draft CAS

DemoStore: synthetic provider adapters

ISecurityAudit: metadata only

\ No newline at end of file diff --git a/docs/components/coursework/diagrams/acc04-dfd.mmd b/docs/components/coursework/diagrams/acc04-dfd.mmd new file mode 100644 index 0000000..e2d6827 --- /dev/null +++ b/docs/components/coursework/diagrams/acc04-dfd.mmd @@ -0,0 +1,27 @@ +flowchart TD + subgraph C["Trust boundary 1: untrusted client"] + ACTOR["Student / teacher"] --> INPUT["Attempt ID / draft answer / expected version"] + end + subgraph S["Trusted server: Development-only synthetic session"] + API["Resolve session subject and validate input"] --> CHECK["Attempt service and shared ACC-04 checks"] + CHECK -->|Allow| IO["Read answer or update own draft with CAS"] + CHECK -->|No session, deny or unavailable| DENY["Normalize rejection without resource details"] + API -->|Transport validation failure: 400| OUT + IO -->|Content or authorized version conflict| OUT["Public response"] + IO -->|Snapshot changed or unavailable| DENY + DENY -->|400 / 401 / generic 404| OUT + IO -->|Actor, action, IDs, versions, outcome| AUD[("Metadata-only audit")] + DENY -->|Internal reason and correlation| AUD + end + subgraph P["Trust boundary 2: trusted provider ports / simulator"] + ID[("Accounts and course memberships")] + ATT[("Attempt metadata and answers")] + end + INPUT -->|HTTP request| API + CHECK -->|Account and course IDs| ID + ID -->|Current account, membership or failure| CHECK + CHECK -->|Attempt ID| ATT + ATT -->|Ownership, course, state, version or failure| CHECK + IO -->|Authorized snapshot and optional draft write| ATT + ATT -->|Bound answer, next version or failure| IO + OUT -->|HTTP status and permitted body| ACTOR diff --git a/docs/components/coursework/diagrams/acc04-dfd.svg b/docs/components/coursework/diagrams/acc04-dfd.svg new file mode 100644 index 0000000..53593d0 --- /dev/null +++ b/docs/components/coursework/diagrams/acc04-dfd.svg @@ -0,0 +1 @@ +Trust boundary 2: trusted provider ports / simulatorTrusted server: Development-only synthetic sessionTrust boundary 1: untrusted clientAllowNo session, deny orunavailableTransport validationfailure: 400Content or authorizedversion conflictSnapshot changed orunavailable400 / 401 / generic 404Actor, action, IDs,versions, outcomeInternal reason andcorrelationHTTP requestAccount and course IDsCurrent account,membership or failureAttempt IDOwnership, course,state, version or failureAuthorized snapshot andoptional draft writeBound answer, nextversion or failureHTTP status andpermitted body

Student / teacher

Attempt ID / draft answer / expected version

Resolve session subject and validate input

Attempt service and shared ACC-04 checks

Read answer or update own draft with CAS

Normalize rejection without resource details

Public response

Metadata-only audit

Accounts and course memberships

Attempt metadata and answers

\ No newline at end of file diff --git a/docs/components/coursework/diagrams/access-admin-domain.mmd b/docs/components/coursework/diagrams/access-admin-domain.mmd new file mode 100644 index 0000000..fa638c9 --- /dev/null +++ b/docs/components/coursework/diagrams/access-admin-domain.mmd @@ -0,0 +1,39 @@ +classDiagram + class Account { + Guid Id + bool IsActive + } + class Membership { + Guid AccountId + Guid CourseId + CourseRole Role + bool IsActive + } + class Attempt { + Guid Id + Guid OwnerId + Guid CourseId + AttemptState State + int Version + } + class Course { + Guid Id + string DisplayName + int Version + } + class AdminGrant { + Guid Id + Guid GranteeId + Guid GrantedBy + Guid CourseId + AdminCapability Capabilities + GrantState State + int Version + bool IsRoot + } + Account "1" --> "many" Membership : current member + Course "1" --> "many" Membership : course scope + Account "1" --> "many" Attempt : owns + Course "1" --> "many" Attempt : scopes + Account "1" --> "many" AdminGrant : receives + Course "1" --> "many" AdminGrant : administrative scope diff --git a/docs/components/coursework/diagrams/access-admin-domain.svg b/docs/components/coursework/diagrams/access-admin-domain.svg new file mode 100644 index 0000000..f2aebac --- /dev/null +++ b/docs/components/coursework/diagrams/access-admin-domain.svg @@ -0,0 +1 @@ +current member1manycourse scope1manyowns1manyscopes1manyreceives1manyadministrative scope1many

Account

Guid Id

bool IsActive

Membership

Guid AccountId

Guid CourseId

CourseRole Role

bool IsActive

Attempt

Guid Id

Guid OwnerId

Guid CourseId

AttemptState State

int Version

Course

Guid Id

string DisplayName

int Version

AdminGrant

Guid Id

Guid GranteeId

Guid GrantedBy

Guid CourseId

AdminCapability Capabilities

GrantState State

int Version

bool IsRoot

\ No newline at end of file diff --git a/docs/components/coursework/diagrams/access-admin-flow.mmd b/docs/components/coursework/diagrams/access-admin-flow.mmd new file mode 100644 index 0000000..98c11e4 --- /dev/null +++ b/docs/components/coursework/diagrams/access-admin-flow.mmd @@ -0,0 +1,11 @@ +flowchart TD + R["Protected request"] --> V["Validate input and resolve synthetic cookie subject"] + V --> S["Resolve current account and resource snapshots"] + S --> D{"Shared engine returns Allow?"} + D -->|No / unavailable| DENY["Deny: public generic response + internal audit"] + D -->|Yes| KIND{"Requested operation"} + KIND -->|Attempt| A["Snapshot-bound read or atomic draft CAS"] + KIND -->|Course administration| G["Current active grant bound to course and capability"] + G --> M["Recheck exact grant at read / commit boundary"] + A --> E["Audit metadata and return result"] + M --> E diff --git a/docs/components/coursework/diagrams/access-admin-flow.svg b/docs/components/coursework/diagrams/access-admin-flow.svg new file mode 100644 index 0000000..89bed6f --- /dev/null +++ b/docs/components/coursework/diagrams/access-admin-flow.svg @@ -0,0 +1 @@ +No / unavailableYesAttemptCourse administration

Protected request

Validate input and resolve synthetic cookie subject

Resolve current account and resource snapshots

Shared engine returns Allow?

Deny: public generic response + internal audit

Requested operation

Snapshot-bound read or atomic draft CAS

Current active grant bound to course and capability

Recheck exact grant at read / commit boundary

Audit metadata and return result

\ No newline at end of file diff --git a/docs/components/coursework/diagrams/adm08-architecture.mmd b/docs/components/coursework/diagrams/adm08-architecture.mmd new file mode 100644 index 0000000..9892b85 --- /dev/null +++ b/docs/components/coursework/diagrams/adm08-architecture.mmd @@ -0,0 +1,10 @@ +flowchart TD + UI["React course administration demo"] -->|HTTP / cookie / input| API["KT1 administration endpoints"] + API --> SERVICE["AdministrationService: view / rename / delegate / revoke"] + SERVICE -->|AccessRequest| ENGINE["Shared IAccessDecision: default deny"] + ENGINE --> POLICY["AdministrationPolicy: explicit same-course capability"] + POLICY --> ACCOUNTS["IAccountReader: current account"] + POLICY --> STORE["ICourseAdministrationStore: current grants / courses"] + SERVICE -->|Captured authority + version| STORE + STORE --> SIM["DemoStore: recheck grant under mutation lock"] + SERVICE --> AUDIT["ISecurityAudit: change metadata"] diff --git a/docs/components/coursework/diagrams/adm08-architecture.svg b/docs/components/coursework/diagrams/adm08-architecture.svg new file mode 100644 index 0000000..9507b65 --- /dev/null +++ b/docs/components/coursework/diagrams/adm08-architecture.svg @@ -0,0 +1 @@ +HTTP / cookie / inputAccessRequestCaptured authority +version

React course administration demo

KT1 administration endpoints

AdministrationService: view / rename / delegate / revoke

Shared IAccessDecision: default deny

AdministrationPolicy: explicit same-course capability

IAccountReader: current account

ICourseAdministrationStore: current grants / courses

DemoStore: recheck grant under mutation lock

ISecurityAudit: change metadata

\ No newline at end of file diff --git a/docs/components/coursework/diagrams/adm08-dfd.mmd b/docs/components/coursework/diagrams/adm08-dfd.mmd new file mode 100644 index 0000000..eef5de1 --- /dev/null +++ b/docs/components/coursework/diagrams/adm08-dfd.mmd @@ -0,0 +1,32 @@ +flowchart TD + subgraph C["Trust boundary 1: untrusted client"] + ACTOR["Course admin / delegate / teacher"] --> INPUT["Course ID / grantee / capabilities / name / version"] + end + subgraph S["Trusted server: Development-only synthetic session"] + API["Resolve session subject and validate input"] --> DEC["Administration service and shared ADM-08 checks"] + DEC -->|Allow| MUT["View / rename / delegate / revoke"] + DEC -->|No session, deny or unavailable| DENY["Normalize rejection without private details"] + API -->|Transport validation failure: 400| OUT + MUT -->|Authorized metadata or version conflict| OUT["Public response; never student answers"] + MUT -->|Authority changed or unavailable| DENY + DENY -->|400 / 401 / generic 404| OUT + MUT -->|Actor, action, IDs, versions, outcome| AUD[("Metadata-only audit")] + DENY -->|Internal reason and correlation| AUD + end + subgraph P["Trust boundary 2: trusted provider and store ports"] + ACC[("Accounts")] + GRANTS[("Course administrative grants")] + COURSES[("Course metadata and version")] + end + INPUT -->|HTTP request| API + DEC -->|Account ID| ACC + ACC -->|Current account or failure| DEC + DEC -->|Actor and course IDs| GRANTS + GRANTS -->|Current scoped authority or failure| DEC + DEC -->|Course ID| COURSES + COURSES -->|Course snapshot or failure| DEC + MUT -->|Capture and atomically compare authority| GRANTS + GRANTS -->|Current grant, grant change or rejection| MUT + MUT -->|Bound read or versioned metadata write| COURSES + COURSES -->|Metadata, next version or failure| MUT + OUT -->|HTTP status and permitted body| ACTOR diff --git a/docs/components/coursework/diagrams/adm08-dfd.svg b/docs/components/coursework/diagrams/adm08-dfd.svg new file mode 100644 index 0000000..a3638d1 --- /dev/null +++ b/docs/components/coursework/diagrams/adm08-dfd.svg @@ -0,0 +1 @@ +Trust boundary 2: trusted provider and store portsTrusted server: Development-only synthetic sessionTrust boundary 1: untrusted clientAllowNo session, deny orunavailableTransport validationfailure: 400Authorized metadata orversion conflictAuthority changed orunavailable400 / 401 / generic 404Actor, action, IDs,versions, outcomeInternal reason andcorrelationHTTP requestAccount IDCurrent account orfailureActor and course IDsCurrent scopedauthority or failureCourse IDCourse snapshot orfailureCapture and atomicallycompare authorityCurrent grant, grantchange or rejectionBound read or versionedmetadata writeMetadata, next versionor failureHTTP status andpermitted body

Course admin / delegate / teacher

Course ID / grantee / capabilities / name / version

Resolve session subject and validate input

Administration service and shared ADM-08 checks

View / rename / delegate / revoke

Normalize rejection without private details

Public response; never student answers

Metadata-only audit

Accounts

Course administrative grants

Course metadata and version

\ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 634821c..8b3eab8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import CourseworkKt1 from './components/CourseworkKt1' import { useEffect, useState } from 'react' export default function App() { @@ -12,5 +13,6 @@ export default function App() {

Security Lab

An interactive laboratory platform for the Fundamentals of Information Security course. Develop features through the assigned project components.

Development status

{health}

KT1: initial implementation, component architecture, and data flow diagram.

+ } diff --git a/frontend/src/components/CourseworkKt1.tsx b/frontend/src/components/CourseworkKt1.tsx new file mode 100644 index 0000000..31d0d76 --- /dev/null +++ b/frontend/src/components/CourseworkKt1.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +const id = (group: number, number: number) => `${group}0000000-0000-0000-0000-${String(number).padStart(12, '0')}` +type Attempt = { id: string; answer: string; version: number } +type Course = { courseId: string; displayName: string; version: number; capabilities: string[] } +type Grant = { id: string; version: number; state: string } + +export default function CourseworkKt1() { + const [tab, setTab] = useState<'attempt' | 'admin'>('attempt') + const [persona, setPersona] = useState('ana') + const [session, setSession] = useState('Anonymous') + const [busy, setBusy] = useState(false) + const [result, setResult] = useState('Choose a synthetic account, then sign in.') + const [attemptId, setAttemptId] = useState(id(3, 1)) + const [attempt, setAttempt] = useState(null) + const [answer, setAnswer] = useState('') + const [courseId, setCourseId] = useState(id(2, 1)) + const [course, setCourse] = useState(null) + const [title, setTitle] = useState('') + const [grant, setGrant] = useState(null) + const [canRename, setCanRename] = useState(true) + const [revokeId, setRevokeId] = useState('') + const [revokeVersion, setRevokeVersion] = useState(1) + function clearPrivateState() { setAttempt(null); setAnswer(''); setCourse(null); setTitle(''); setGrant(null) } + async function request(path: string, method = 'GET', body?: unknown): Promise { + setBusy(true) + try { + const response = await fetch(`/api/kt1${path}`, { + method, credentials: 'same-origin', cache: 'no-store', + headers: method === 'GET' ? {} : { 'X-Kt1-Demo': '1', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await response.text() + setResult(`HTTP ${response.status}\n${text ? JSON.stringify(JSON.parse(text), null, 2) : 'Session cleared.'}`) + return response.ok && text ? JSON.parse(text) as T : null + } catch { setResult('API unavailable. Start the AccessKt1 backend profile.'); return null } + finally { setBusy(false) } + } + async function login() { clearPrivateState(); setSession('Anonymous'); if (await request(`/session/${persona}`, 'POST')) setSession(persona) } + async function logout() { await request('/logout', 'POST'); clearPrivateState(); setSession('Anonymous') } + async function loadAttempt() { + setAttempt(null); setAnswer('') + const value = await request(`/attempts/${attemptId}`) + if (value) { setAttempt(value); setAnswer(value.answer) } + } + async function saveDraft() { + if (!attempt) return + const value = await request(`/attempts/${attemptId}/answer`, 'PUT', { answer, expectedVersion: attempt.version }) + setAttempt(value); if (value) setAnswer(value.answer); else setAnswer('') + } + async function loadCourse() { + setCourse(null); setTitle('') + const value = await request(`/courses/${courseId}/administration`) + if (value) { setCourse(value); setTitle(value.displayName) } + } + async function rename() { + if (!course) return + const value = await request(`/courses/${courseId}/settings`, 'PUT', { displayName: title, expectedVersion: course.version }) + setCourse(value); if (!value) setTitle('') + } + async function delegate() { + const value = await request(`/courses/${courseId}/delegations`, 'POST', { + granteeId: id(1, 9), capabilities: canRename ? ['ViewAdministration', 'ManageSettings'] : ['ViewAdministration'], + }) + setGrant(value); if (value) { setRevokeId(value.id); setRevokeVersion(value.version) } + } + async function revoke() { + if (!revokeId) return + const value = await request(`/courses/${courseId}/delegations/${revokeId}/revoke`, 'POST', { expectedVersion: revokeVersion }) + setGrant(value); if (value) setRevokeVersion(value.version) + } + return
+

ACC-04 + ADM-08

Ownership & course administration

KT1 simulator
+

Development only · synthetic accounts and in-memory seed · restart the API to reset. Administrative grants never give teaching access.

+
Synthetic session +

Session: {session}

+
+
+ + +
+
{tab === 'attempt' ? 'Resource access' : 'Course delegation'} + {tab === 'attempt' ? <> + + +