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 @@
+