diff --git a/backend/Platform.Api/Platform.Api.csproj b/backend/Platform.Api/Platform.Api.csproj index 26028e0..12d9fd7 100644 --- a/backend/Platform.Api/Platform.Api.csproj +++ b/backend/Platform.Api/Platform.Api.csproj @@ -10,6 +10,7 @@ + diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index 6859d18..82a6d87 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -14,6 +14,9 @@ using Platform.Api.Modules.Identity.RateLimiting; 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 IAccessDecision, zato koristimo alias. using IdentityAccessDecision = Platform.Api.Services.Identity.IAccessDecision; @@ -38,6 +41,7 @@ // Servisi sa main grane builder.Services.AddAudit(); +builder.Services.AddLabVisibility(); builder.Services.AddIdentityRateLimiting(builder.Configuration); builder.Services.AddControllers(); builder.Services.AddHttpContextAccessor(); @@ -73,6 +77,9 @@ app.Services.RegisterIdentityAccess(); app.MapAccountEndpoints(); +// AUT-04/AUT-07: radnje i politike za prikaz laboratorije +app.Services.RegisterLabVisibilityAccess(); + // Probni nalozi samo za lokalni razvoj if (app.Environment.IsDevelopment()) { @@ -89,6 +96,12 @@ 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 2f30a35..56256f8 100644 --- a/backend/SecurityLab.slnx +++ b/backend/SecurityLab.slnx @@ -6,6 +6,7 @@ +