Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ jobs:
echo "::error::Zero tests were executed; a green run with no tests proves nothing."
exit 1
fi
- name: Live KT1 demonstration
shell: bash
run: |
ASPNETCORE_ENVIRONMENT=Development Kt1Demo__Enabled=true dotnet backend/Platform.Api/bin/Release/net10.0/Platform.Api.dll --contentRoot "$GITHUB_WORKSPACE/backend/Platform.Api" --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: error
- name: Upload backend test results
if: always()
uses: actions/upload-artifact@v4
Expand All @@ -54,4 +74,4 @@ jobs:
run: npm ci --no-audit --no-fund
- name: Build frontend
working-directory: frontend
run: npm run build
run: npm run build
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,15 @@ If you add a backend module or a test project, add it to the solution so that CI
Use short-lived branches and pull requests for changes (for example `feature/AUT-01-AUT-06-lab-drafts`), and mention the requirement IDs from the specification in commits and pull requests. See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow. Keep credentials and other secrets out of the repository.

See the course specification for the full requirements.

## 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.
47 changes: 47 additions & 0 deletions backend/Platform.Api/Modules/Access/AttemptPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Platform.Access.Contracts;
using Platform.Access.Domain.Models;

namespace Platform.Api.Modules.Access;

/// <summary>Concrete ACC-04 policy, registered in the existing shared default-deny engine.</summary>
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<bool> 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";
}
}
62 changes: 62 additions & 0 deletions backend/Platform.Api/Modules/Access/AttemptService.cs
Original file line number Diff line number Diff line change
@@ -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<OperationResult> 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"); }
}
}
33 changes: 33 additions & 0 deletions backend/Platform.Api/Modules/Access/Contracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Platform.Api.Modules.Access;

public enum PortStatus { Ok, NotFound, Unavailable }
public sealed record PortResult<T>(PortStatus Status, T? Value) where T : class
{
public static PortResult<T> Found(T value) => new(PortStatus.Ok, value);
public static PortResult<T> Missing() => new(PortStatus.NotFound, null);
public static PortResult<T> 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<AccountSnapshot> FindAccount(Guid id); }
public interface IMembershipReader { PortResult<MembershipSnapshot> FindMembership(Guid actorId, Guid courseId); }
public interface IAttemptStore
{
PortResult<AttemptSnapshot> FindAttempt(Guid id);
PortResult<AttemptView> ReadAnswer(AttemptSnapshot authorizedSnapshot);
WriteResult UpdateDraft(AttemptSnapshot authorizedSnapshot, int expectedVersion, string answer);
}
public interface ISecurityAudit { void Write(SecurityEvent entry); }
Original file line number Diff line number Diff line change
@@ -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<bool> 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);
}
Loading
Loading