Skip to content
Open
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
1 change: 1 addition & 0 deletions backend/Platform.Api/Platform.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Platform.Access\Platform.Access.csproj" />
<ProjectReference Include="..\Platform.LabVisibility\Platform.LabVisibility.csproj" />
<ProjectReference Include="..\Platform.Audit\Platform.Audit.csproj" />
</ItemGroup>
</Project>
12 changes: 12 additions & 0 deletions backend/Platform.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +18,7 @@

builder.Services.AddAccessControl();
builder.Services.AddAudit();
builder.Services.AddLabVisibility();
builder.Services.AddControllers();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(TimeProvider.System);
Expand All @@ -40,13 +44,21 @@

var app = builder.Build();

app.Services.RegisterLabVisibilityAccess();

// Probni nalozi samo za lokalni razvoj (izmišljeni domen example.com).
if (app.Environment.IsDevelopment())
{
var repo = app.Services.GetRequiredService<IAccountRepository>();
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<InMemoryCourseMembership>();
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StudentTaskView> Tasks);

public sealed record StudentTaskView(
string TaskId,
int Order,
string Prompt,
IReadOnlyList<string> Options);
Original file line number Diff line number Diff line change
@@ -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<InMemoryLabPublicContentReader>();
services.AddSingleton<ILabPublicContentReader>(sp => sp.GetRequiredService<InMemoryLabPublicContentReader>());

services.AddSingleton<InMemoryEvaluatorKeyStore>();
services.AddSingleton<IEvaluatorKeyStore>(sp => sp.GetRequiredService<InMemoryEvaluatorKeyStore>());

services.AddSingleton<InMemoryCourseMembership>();
services.AddSingleton<ICourseMembership>(sp => sp.GetRequiredService<InMemoryCourseMembership>());

services.AddSingleton<ILabVisibilityAudit, NullLabVisibilityAudit>();

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<InMemoryActionCatalog>();
foreach (var action in LabVisibilityActions.All)
{
catalog.Register(action);
}

// Politike se dodaju ovde, svako svoju:
// var policies = services.GetRequiredService<InMemoryAccessPolicyRegistry>();
// policies.Register(LabVisibilityActions.StudentView, new StudentViewPolicy(...));

return services;
}
}
14 changes: 14 additions & 0 deletions backend/Platform.LabVisibility/Contracts/ICourseMembership.cs
Original file line number Diff line number Diff line change
@@ -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<CourseRole> GetRoleAsync(Guid courseId, Guid userId, CancellationToken cancellationToken = default);
}
11 changes: 11 additions & 0 deletions backend/Platform.LabVisibility/Contracts/IEvaluatorKeyStore.cs
Original file line number Diff line number Diff line change
@@ -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<EvaluatorKey?> GetAsync(Guid labId, CancellationToken cancellationToken = default);

ValueTask SaveAsync(EvaluatorKey key, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Platform.LabVisibility.Domain;

namespace Platform.LabVisibility.Contracts;

public interface ILabPublicContentReader
{
ValueTask<LabPublicContent?> GetAsync(Guid labId, CancellationToken cancellationToken = default);
}
14 changes: 14 additions & 0 deletions backend/Platform.LabVisibility/Contracts/ILabVisibilityAudit.cs
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 10 additions & 0 deletions backend/Platform.LabVisibility/Contracts/LabVisibilityActions.cs
Original file line number Diff line number Diff line change
@@ -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<string> All = [StudentView, Preview, KeyRead];
}
10 changes: 10 additions & 0 deletions backend/Platform.LabVisibility/Domain/EvaluatorKey.cs
Original file line number Diff line number Diff line change
@@ -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<TaskAnswerKey> Answers);

public sealed record TaskAnswerKey(
string TaskId,
string ExpectedAnswer);
17 changes: 17 additions & 0 deletions backend/Platform.LabVisibility/Domain/LabPublicContent.cs
Original file line number Diff line number Diff line change
@@ -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<PublicTask> Tasks);

public sealed record PublicTask(
string TaskId,
int Order,
string Prompt,
IReadOnlyList<string> Options);
8 changes: 8 additions & 0 deletions backend/Platform.LabVisibility/Domain/LabVisibilityState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Platform.LabVisibility.Domain;

public enum LabVisibilityState
{
Draft,
Published,
Archived
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<CourseRole> GetRoleAsync(Guid courseId, Guid userId, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(roles.TryGetValue((courseId, userId), out var role) ? role : CourseRole.None);
}
Original file line number Diff line number Diff line change
@@ -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<Guid, EvaluatorKey> keys = new();

public ValueTask<EvaluatorKey?> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Guid, LabPublicContent> labs = new();

public void Save(LabPublicContent content) => labs[content.LabId] = content;

public ValueTask<LabPublicContent?> GetAsync(Guid labId, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(labs.TryGetValue(labId, out var content) ? content : null);
}
Empty file.
13 changes: 13 additions & 0 deletions backend/Platform.LabVisibility/Platform.LabVisibility.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Platform.Access\Platform.Access.csproj" />
</ItemGroup>
</Project>
71 changes: 71 additions & 0 deletions backend/Platform.Tests/LabVisibility/LabVisibilitySkeletonTests.cs
Original file line number Diff line number Diff line change
@@ -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<IAccessDecision>()
.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<IAccessDecision>()
.DecideAsync(ActiveStudentRequest("lab.answers.dump"));

Assert.That(decision.DenialReason, Is.EqualTo(AccessDenialReason.UnknownAction));
}

[Test]
public void RegisteringTwice_FailsAtStartup()
{
using var provider = BuildProvider();
Assert.Throws<InvalidOperationException>(() => provider.RegisterLabVisibilityAccess());
}

[Test]
public async Task UnknownMember_HasNoRoleOnCourse()
{
await using var provider = BuildProvider();
var role = await provider.GetRequiredService<ICourseMembership>()
.GetRoleAsync(Guid.NewGuid(), Guid.NewGuid());

Assert.That(role, Is.EqualTo(CourseRole.None));
}
}
1 change: 1 addition & 0 deletions backend/SecurityLab.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
<Project Path="Platform.Api/Platform.Api.csproj" />
<Project Path="Platform.Audit/Platform.Audit.csproj" />
<Project Path="Platform.Audit.Tests/Platform.Audit.Tests.csproj" />
<Project Path="Platform.LabVisibility/Platform.LabVisibility.csproj" />
<Project Path="Platform.Tests/Platform.Tests.csproj" />
</Solution>
Loading