From 82dc593b990f6a7e61ecdcdba214b046b1306f15 Mon Sep 17 00:00:00 2001 From: Dusan Kovacevic Date: Tue, 3 Nov 2026 06:42:58 +0100 Subject: [PATCH] AUT-04: add Platform.LabVisibility skeleton and contracts --- backend/Platform.Api/Platform.Api.csproj | 1 + backend/Platform.Api/Program.cs | 12 ++++ .../Application/StudentView/StudentLabView.cs | 14 ++++ ...abVisibilityServiceCollectionExtensions.cs | 44 ++++++++++++ .../Contracts/ICourseMembership.cs | 14 ++++ .../Contracts/IEvaluatorKeyStore.cs | 11 +++ .../Contracts/ILabPublicContentReader.cs | 8 +++ .../Contracts/ILabVisibilityAudit.cs | 14 ++++ .../Contracts/LabVisibilityActions.cs | 10 +++ .../Domain/EvaluatorKey.cs | 10 +++ .../Domain/LabPublicContent.cs | 17 +++++ .../Domain/LabVisibilityState.cs | 8 +++ .../Auditing/NullLabVisibilityAudit.cs | 10 +++ .../Membership/InMemoryCourseMembership.cs | 15 ++++ .../Persistence/InMemoryEvaluatorKeyStore.cs | 20 ++++++ .../InMemoryLabPublicContentReader.cs | 16 +++++ .../Infrastructure/Policies/.gitkeep | 0 .../Platform.LabVisibility.csproj | 13 ++++ .../LabVisibilitySkeletonTests.cs | 71 +++++++++++++++++++ backend/SecurityLab.slnx | 1 + 20 files changed, 309 insertions(+) create mode 100644 backend/Platform.LabVisibility/Application/StudentView/StudentLabView.cs create mode 100644 backend/Platform.LabVisibility/Composition/LabVisibilityServiceCollectionExtensions.cs create mode 100644 backend/Platform.LabVisibility/Contracts/ICourseMembership.cs create mode 100644 backend/Platform.LabVisibility/Contracts/IEvaluatorKeyStore.cs create mode 100644 backend/Platform.LabVisibility/Contracts/ILabPublicContentReader.cs create mode 100644 backend/Platform.LabVisibility/Contracts/ILabVisibilityAudit.cs create mode 100644 backend/Platform.LabVisibility/Contracts/LabVisibilityActions.cs create mode 100644 backend/Platform.LabVisibility/Domain/EvaluatorKey.cs create mode 100644 backend/Platform.LabVisibility/Domain/LabPublicContent.cs create mode 100644 backend/Platform.LabVisibility/Domain/LabVisibilityState.cs create mode 100644 backend/Platform.LabVisibility/Infrastructure/Auditing/NullLabVisibilityAudit.cs create mode 100644 backend/Platform.LabVisibility/Infrastructure/Membership/InMemoryCourseMembership.cs create mode 100644 backend/Platform.LabVisibility/Infrastructure/Persistence/InMemoryEvaluatorKeyStore.cs create mode 100644 backend/Platform.LabVisibility/Infrastructure/Persistence/InMemoryLabPublicContentReader.cs create mode 100644 backend/Platform.LabVisibility/Infrastructure/Policies/.gitkeep create mode 100644 backend/Platform.LabVisibility/Platform.LabVisibility.csproj create mode 100644 backend/Platform.Tests/LabVisibility/LabVisibilitySkeletonTests.cs diff --git a/backend/Platform.Api/Platform.Api.csproj b/backend/Platform.Api/Platform.Api.csproj index af34409..6bcdb3e 100644 --- a/backend/Platform.Api/Platform.Api.csproj +++ b/backend/Platform.Api/Platform.Api.csproj @@ -6,6 +6,7 @@ + diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index 513139b..d16f130 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -6,6 +6,9 @@ using Platform.Api.Middlewares; using Platform.Api.Services.Identity; using Platform.Audit.Composition; +using Platform.LabVisibility.Composition; +using Platform.LabVisibility.Contracts; +using Platform.LabVisibility.Infrastructure.Membership; // Oba modula imaju interfejs IAccessDecision, pa se koriste aliasi da ime ne bude dvosmisleno. using IdentityAccessDecision = Platform.Api.Services.Identity.IAccessDecision; @@ -15,6 +18,7 @@ builder.Services.AddAccessControl(); builder.Services.AddAudit(); +builder.Services.AddLabVisibility(); builder.Services.AddControllers(); builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(TimeProvider.System); @@ -40,6 +44,8 @@ var app = builder.Build(); +app.Services.RegisterLabVisibilityAccess(); + // Probni nalozi samo za lokalni razvoj (izmišljeni domen example.com). if (app.Environment.IsDevelopment()) { @@ -47,6 +53,12 @@ repo.Save(new UserAccount(Guid.Parse("11111111-1111-1111-1111-111111111111"), "admin@example.com")); repo.Save(new UserAccount(Guid.Parse("22222222-2222-2222-2222-222222222222"), "student@example.com")); repo.Save(new UserAccount(Guid.Parse("33333333-3333-3333-3333-333333333333"), "other@example.com")); + + // AUT-04/AUT-07 demo kurs: 1111 je nastavnik, 2222 student, 3333 nije na kursu. + var demoCourse = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + var membership = app.Services.GetRequiredService(); + membership.Set(demoCourse, Guid.Parse("11111111-1111-1111-1111-111111111111"), CourseRole.Teacher); + membership.Set(demoCourse, Guid.Parse("22222222-2222-2222-2222-222222222222"), CourseRole.Student); } app.MapGet("/api/health", () => Results.Ok(new diff --git a/backend/Platform.LabVisibility/Application/StudentView/StudentLabView.cs b/backend/Platform.LabVisibility/Application/StudentView/StudentLabView.cs new file mode 100644 index 0000000..c50c6d6 --- /dev/null +++ b/backend/Platform.LabVisibility/Application/StudentView/StudentLabView.cs @@ -0,0 +1,14 @@ +namespace Platform.LabVisibility.Application.StudentView; + +// Oblik koji ide klijentu (studentski prikaz i pregled pre objave). Samo javna polja. +public sealed record StudentLabView( + Guid LabId, + string Title, + string Goal, + IReadOnlyList Tasks); + +public sealed record StudentTaskView( + string TaskId, + int Order, + string Prompt, + IReadOnlyList Options); diff --git a/backend/Platform.LabVisibility/Composition/LabVisibilityServiceCollectionExtensions.cs b/backend/Platform.LabVisibility/Composition/LabVisibilityServiceCollectionExtensions.cs new file mode 100644 index 0000000..04fd181 --- /dev/null +++ b/backend/Platform.LabVisibility/Composition/LabVisibilityServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.DependencyInjection; +using Platform.Access.Infrastructure.Catalog; +using Platform.LabVisibility.Contracts; +using Platform.LabVisibility.Infrastructure.Auditing; +using Platform.LabVisibility.Infrastructure.Membership; +using Platform.LabVisibility.Infrastructure.Persistence; + +namespace Platform.LabVisibility.Composition; + +public static class LabVisibilityServiceCollectionExtensions +{ + // Poziva se posle AddAccessControl(). Svako dodaje svoju liniju ovde. + public static IServiceCollection AddLabVisibility(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(); + + return services; + } + + // Poziva se jednom, posle builder.Build(). Radnje bez politike tim 3 odbija (NoPolicy). + public static IServiceProvider RegisterLabVisibilityAccess(this IServiceProvider services) + { + var catalog = services.GetRequiredService(); + foreach (var action in LabVisibilityActions.All) + { + catalog.Register(action); + } + + // Politike se dodaju ovde, svako svoju: + // var policies = services.GetRequiredService(); + // policies.Register(LabVisibilityActions.StudentView, new StudentViewPolicy(...)); + + return services; + } +} diff --git a/backend/Platform.LabVisibility/Contracts/ICourseMembership.cs b/backend/Platform.LabVisibility/Contracts/ICourseMembership.cs new file mode 100644 index 0000000..df8683b --- /dev/null +++ b/backend/Platform.LabVisibility/Contracts/ICourseMembership.cs @@ -0,0 +1,14 @@ +namespace Platform.LabVisibility.Contracts; + +public enum CourseRole +{ + None, + Student, + Teacher +} + +// Privremeni ugovor dok ACC timovi ne objave članstvo na kursu. +public interface ICourseMembership +{ + ValueTask GetRoleAsync(Guid courseId, Guid userId, CancellationToken cancellationToken = default); +} diff --git a/backend/Platform.LabVisibility/Contracts/IEvaluatorKeyStore.cs b/backend/Platform.LabVisibility/Contracts/IEvaluatorKeyStore.cs new file mode 100644 index 0000000..2f71f4e --- /dev/null +++ b/backend/Platform.LabVisibility/Contracts/IEvaluatorKeyStore.cs @@ -0,0 +1,11 @@ +using Platform.LabVisibility.Domain; + +namespace Platform.LabVisibility.Contracts; + +// Sme da ga koristi samo EvaluatorKeyReader. Kontroleri ga nikad ne dobijaju. +public interface IEvaluatorKeyStore +{ + ValueTask GetAsync(Guid labId, CancellationToken cancellationToken = default); + + ValueTask SaveAsync(EvaluatorKey key, CancellationToken cancellationToken = default); +} diff --git a/backend/Platform.LabVisibility/Contracts/ILabPublicContentReader.cs b/backend/Platform.LabVisibility/Contracts/ILabPublicContentReader.cs new file mode 100644 index 0000000..cdf3153 --- /dev/null +++ b/backend/Platform.LabVisibility/Contracts/ILabPublicContentReader.cs @@ -0,0 +1,8 @@ +using Platform.LabVisibility.Domain; + +namespace Platform.LabVisibility.Contracts; + +public interface ILabPublicContentReader +{ + ValueTask GetAsync(Guid labId, CancellationToken cancellationToken = default); +} diff --git a/backend/Platform.LabVisibility/Contracts/ILabVisibilityAudit.cs b/backend/Platform.LabVisibility/Contracts/ILabVisibilityAudit.cs new file mode 100644 index 0000000..0b855bc --- /dev/null +++ b/backend/Platform.LabVisibility/Contracts/ILabVisibilityAudit.cs @@ -0,0 +1,14 @@ +namespace Platform.LabVisibility.Contracts; + +// Namerno nema polje za sadržaj: odgovor ne može da završi u auditu (AUD-03). +public sealed record LabVisibilityAuditEvent( + string Action, + Guid LabId, + string? ActorId, + string Outcome, + string CorrelationId); + +public interface ILabVisibilityAudit +{ + ValueTask WriteAsync(LabVisibilityAuditEvent auditEvent, CancellationToken cancellationToken = default); +} diff --git a/backend/Platform.LabVisibility/Contracts/LabVisibilityActions.cs b/backend/Platform.LabVisibility/Contracts/LabVisibilityActions.cs new file mode 100644 index 0000000..9092b27 --- /dev/null +++ b/backend/Platform.LabVisibility/Contracts/LabVisibilityActions.cs @@ -0,0 +1,10 @@ +namespace Platform.LabVisibility.Contracts; + +public static class LabVisibilityActions +{ + public const string StudentView = "lab.student.view"; + public const string Preview = "lab.preview"; + public const string KeyRead = "lab.key.read"; + + public static readonly IReadOnlyList All = [StudentView, Preview, KeyRead]; +} diff --git a/backend/Platform.LabVisibility/Domain/EvaluatorKey.cs b/backend/Platform.LabVisibility/Domain/EvaluatorKey.cs new file mode 100644 index 0000000..4f0414a --- /dev/null +++ b/backend/Platform.LabVisibility/Domain/EvaluatorKey.cs @@ -0,0 +1,10 @@ +namespace Platform.LabVisibility.Domain; + +// Tačni odgovori. Poseban tip i posebna tabela; nikad ne napušta server (AUT-04). +public sealed record EvaluatorKey( + Guid LabId, + IReadOnlyList Answers); + +public sealed record TaskAnswerKey( + string TaskId, + string ExpectedAnswer); diff --git a/backend/Platform.LabVisibility/Domain/LabPublicContent.cs b/backend/Platform.LabVisibility/Domain/LabPublicContent.cs new file mode 100644 index 0000000..3f42055 --- /dev/null +++ b/backend/Platform.LabVisibility/Domain/LabPublicContent.cs @@ -0,0 +1,17 @@ +namespace Platform.LabVisibility.Domain; + +// Sve što student sme da vidi. Ovde nikad ne dodajemo tačan odgovor (AUT-04). +public sealed record LabPublicContent( + Guid LabId, + Guid CourseId, + Guid AuthorId, + LabVisibilityState State, + string Title, + string Goal, + IReadOnlyList Tasks); + +public sealed record PublicTask( + string TaskId, + int Order, + string Prompt, + IReadOnlyList Options); diff --git a/backend/Platform.LabVisibility/Domain/LabVisibilityState.cs b/backend/Platform.LabVisibility/Domain/LabVisibilityState.cs new file mode 100644 index 0000000..b8cb285 --- /dev/null +++ b/backend/Platform.LabVisibility/Domain/LabVisibilityState.cs @@ -0,0 +1,8 @@ +namespace Platform.LabVisibility.Domain; + +public enum LabVisibilityState +{ + Draft, + Published, + Archived +} diff --git a/backend/Platform.LabVisibility/Infrastructure/Auditing/NullLabVisibilityAudit.cs b/backend/Platform.LabVisibility/Infrastructure/Auditing/NullLabVisibilityAudit.cs new file mode 100644 index 0000000..315e27c --- /dev/null +++ b/backend/Platform.LabVisibility/Infrastructure/Auditing/NullLabVisibilityAudit.cs @@ -0,0 +1,10 @@ +using Platform.LabVisibility.Contracts; + +namespace Platform.LabVisibility.Infrastructure.Auditing; + +// Privremeno. Zamenjuje ga LoggerLabVisibilityAudit (kartica 5). +public sealed class NullLabVisibilityAudit : ILabVisibilityAudit +{ + public ValueTask WriteAsync(LabVisibilityAuditEvent auditEvent, CancellationToken cancellationToken = default) => + ValueTask.CompletedTask; +} diff --git a/backend/Platform.LabVisibility/Infrastructure/Membership/InMemoryCourseMembership.cs b/backend/Platform.LabVisibility/Infrastructure/Membership/InMemoryCourseMembership.cs new file mode 100644 index 0000000..913d2e0 --- /dev/null +++ b/backend/Platform.LabVisibility/Infrastructure/Membership/InMemoryCourseMembership.cs @@ -0,0 +1,15 @@ +using System.Collections.Concurrent; +using Platform.LabVisibility.Contracts; + +namespace Platform.LabVisibility.Infrastructure.Membership; + +// DEV STUB: zameniti ugovorom ACC tima kad ga objave. +public sealed class InMemoryCourseMembership : ICourseMembership +{ + private readonly ConcurrentDictionary<(Guid CourseId, Guid UserId), CourseRole> roles = new(); + + public void Set(Guid courseId, Guid userId, CourseRole role) => roles[(courseId, userId)] = role; + + public ValueTask GetRoleAsync(Guid courseId, Guid userId, CancellationToken cancellationToken = default) => + ValueTask.FromResult(roles.TryGetValue((courseId, userId), out var role) ? role : CourseRole.None); +} diff --git a/backend/Platform.LabVisibility/Infrastructure/Persistence/InMemoryEvaluatorKeyStore.cs b/backend/Platform.LabVisibility/Infrastructure/Persistence/InMemoryEvaluatorKeyStore.cs new file mode 100644 index 0000000..9a27573 --- /dev/null +++ b/backend/Platform.LabVisibility/Infrastructure/Persistence/InMemoryEvaluatorKeyStore.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; +using Platform.LabVisibility.Contracts; +using Platform.LabVisibility.Domain; + +namespace Platform.LabVisibility.Infrastructure.Persistence; + +public sealed class InMemoryEvaluatorKeyStore : IEvaluatorKeyStore +{ + private readonly ConcurrentDictionary keys = new(); + + public ValueTask GetAsync(Guid labId, CancellationToken cancellationToken = default) => + ValueTask.FromResult(keys.TryGetValue(labId, out var key) ? key : null); + + public ValueTask SaveAsync(EvaluatorKey key, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(key); + keys[key.LabId] = key; + return ValueTask.CompletedTask; + } +} diff --git a/backend/Platform.LabVisibility/Infrastructure/Persistence/InMemoryLabPublicContentReader.cs b/backend/Platform.LabVisibility/Infrastructure/Persistence/InMemoryLabPublicContentReader.cs new file mode 100644 index 0000000..4e10865 --- /dev/null +++ b/backend/Platform.LabVisibility/Infrastructure/Persistence/InMemoryLabPublicContentReader.cs @@ -0,0 +1,16 @@ +using System.Collections.Concurrent; +using Platform.LabVisibility.Contracts; +using Platform.LabVisibility.Domain; + +namespace Platform.LabVisibility.Infrastructure.Persistence; + +// Za testove i razvoj dok ne stigne EF implementacija (Antonić). +public sealed class InMemoryLabPublicContentReader : ILabPublicContentReader +{ + private readonly ConcurrentDictionary labs = new(); + + public void Save(LabPublicContent content) => labs[content.LabId] = content; + + public ValueTask GetAsync(Guid labId, CancellationToken cancellationToken = default) => + ValueTask.FromResult(labs.TryGetValue(labId, out var content) ? content : null); +} diff --git a/backend/Platform.LabVisibility/Infrastructure/Policies/.gitkeep b/backend/Platform.LabVisibility/Infrastructure/Policies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/Platform.LabVisibility/Platform.LabVisibility.csproj b/backend/Platform.LabVisibility/Platform.LabVisibility.csproj new file mode 100644 index 0000000..314bb92 --- /dev/null +++ b/backend/Platform.LabVisibility/Platform.LabVisibility.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + + + + + + + + diff --git a/backend/Platform.Tests/LabVisibility/LabVisibilitySkeletonTests.cs b/backend/Platform.Tests/LabVisibility/LabVisibilitySkeletonTests.cs new file mode 100644 index 0000000..324e5a3 --- /dev/null +++ b/backend/Platform.Tests/LabVisibility/LabVisibilitySkeletonTests.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.DependencyInjection; +using Platform.Access.Composition; +using Platform.Access.Contracts; +using Platform.Access.Domain.Enums; +using Platform.Access.Domain.Models; +using Platform.LabVisibility.Composition; +using Platform.LabVisibility.Contracts; + +namespace Platform.Tests.LabVisibility; + +public class LabVisibilitySkeletonTests +{ + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddAccessControl(); + services.AddLabVisibility(); + var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true, + ValidateOnBuild = true + }); + provider.RegisterLabVisibilityAccess(); + return provider; + } + + private static AccessRequest ActiveStudentRequest(string action) => + new(new SubjectRef("22222222-2222-2222-2222-222222222222", AccountState.Active), + action, + new ResourceRef("lab", Guid.NewGuid().ToString()), + Guid.NewGuid().ToString("N")); + + [TestCaseSource(typeof(LabVisibilityActions), nameof(LabVisibilityActions.All))] + public async Task RegisteredActionWithoutPolicy_IsDenied(string action) + { + await using var provider = BuildProvider(); + var decision = await provider.GetRequiredService() + .DecideAsync(ActiveStudentRequest(action)); + + // Radnja je poznata, ali bez politike mora biti odbijena (fail-closed). + Assert.That(decision.Outcome, Is.EqualTo(AccessOutcome.Deny)); + Assert.That(decision.DenialReason, Is.EqualTo(AccessDenialReason.NoPolicy)); + } + + [Test] + public async Task UnregisteredLabAction_IsDeniedAsUnknown() + { + await using var provider = BuildProvider(); + var decision = await provider.GetRequiredService() + .DecideAsync(ActiveStudentRequest("lab.answers.dump")); + + Assert.That(decision.DenialReason, Is.EqualTo(AccessDenialReason.UnknownAction)); + } + + [Test] + public void RegisteringTwice_FailsAtStartup() + { + using var provider = BuildProvider(); + Assert.Throws(() => provider.RegisterLabVisibilityAccess()); + } + + [Test] + public async Task UnknownMember_HasNoRoleOnCourse() + { + await using var provider = BuildProvider(); + var role = await provider.GetRequiredService() + .GetRoleAsync(Guid.NewGuid(), Guid.NewGuid()); + + Assert.That(role, Is.EqualTo(CourseRole.None)); + } +} diff --git a/backend/SecurityLab.slnx b/backend/SecurityLab.slnx index c8a3250..598a722 100644 --- a/backend/SecurityLab.slnx +++ b/backend/SecurityLab.slnx @@ -3,5 +3,6 @@ +