diff --git a/backend/Laboratories.Tests/FakeAccessDecisionTests.cs b/backend/Laboratories.Tests/FakeAccessDecisionTests.cs new file mode 100644 index 0000000..673bc27 --- /dev/null +++ b/backend/Laboratories.Tests/FakeAccessDecisionTests.cs @@ -0,0 +1,69 @@ +using Platform.Api.Modules.Laboratories.Application; +using Platform.Api.Modules.Laboratories.Infrastructure; + +namespace Laboratories.Tests; + +// Testovi politike pristupa: sve sto nije izricito dozvoljeno -> odbijeno. +public class FakeAccessDecisionTests +{ + private readonly FakeAccessDecision Policy = new FakeAccessDecision(); + + [Test] + public void Decide_WhenUserIsUnknown_Denies() + { + IIdentityContext unknownUser = new AnonymousIdentityContext(); + + AccessResult result = Policy.Decide(unknownUser, LabActions.List); + + Assert.That(result.Allowed, Is.False); + Assert.That(result.Reason, Is.EqualTo(DenyReason.Unauthenticated)); + } + + [Test] + public void Decide_WhenActionIsUnknown_Denies() + { + IIdentityContext teacher = new TestUser("marko", PlatformRole.Teacher); + + AccessResult result = Policy.Decide(teacher, "laboratory.delete-everything"); + + Assert.That(result.Allowed, Is.False); + Assert.That(result.Reason, Is.EqualTo(DenyReason.UnknownAction)); + } + + [Test] + public void Decide_WhenStudentCreatesLaboratory_Denies() + { + IIdentityContext student = new TestUser("ana", PlatformRole.Student); + + AccessResult result = Policy.Decide(student, LabActions.Create); + + Assert.That(result.Allowed, Is.False); + Assert.That(result.Reason, Is.EqualTo(DenyReason.NotPermitted)); + } + + [Test] + public void Decide_WhenTeacherCreatesLaboratory_Allows() + { + IIdentityContext teacher = new TestUser("marko", PlatformRole.Teacher); + + AccessResult result = Policy.Decide(teacher, LabActions.Create); + + Assert.That(result.Allowed, Is.True); + } + + //lazni korisnik za potrebe testova + private sealed class TestUser : IIdentityContext + { + public TestUser(string userId, PlatformRole role) + { + UserId = userId; + Role = role; + } + + public bool IsAuthenticated => true; + + public string? UserId { get; } + + public PlatformRole? Role { get; } + } +} \ No newline at end of file diff --git a/backend/Laboratories.Tests/Laboratories.Tests.csproj b/backend/Laboratories.Tests/Laboratories.Tests.csproj new file mode 100644 index 0000000..107c9ac --- /dev/null +++ b/backend/Laboratories.Tests/Laboratories.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/backend/Laboratories.Tests/LaboratoriesHttpTests.cs b/backend/Laboratories.Tests/LaboratoriesHttpTests.cs new file mode 100644 index 0000000..e33e85a --- /dev/null +++ b/backend/Laboratories.Tests/LaboratoriesHttpTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Logging; + +namespace Laboratories.Tests; + +[TestFixture] +public sealed class LaboratoriesHttpTests +{ + private const string Route = "/api/laboratories/"; + private const string TestUserHeader = "X-Test-User"; + + [TestCase("GET", null, HttpStatusCode.Unauthorized)] + [TestCase("POST", null, HttpStatusCode.Unauthorized)] + [TestCase("GET", "ana:Student", HttpStatusCode.OK)] + [TestCase("POST", "ana:Student", HttpStatusCode.Forbidden)] + [TestCase("POST", "marko:Teacher", HttpStatusCode.NotImplemented)] + [TestCase("GET", "ana:Unknown", HttpStatusCode.Unauthorized)] + [TestCase("GET", "invalid", HttpStatusCode.Unauthorized)] + public async Task Development_EnforcesAccessBeforeEndpoint( + string method, string? testUser, HttpStatusCode expectedStatus) + { + using var factory = new LaboratoriesHostFactory("Development"); + using var client = factory.CreateClient(); + using var request = CreateRequest(method, testUser); + + using var response = await client.SendAsync(request); + + Assert.That(response.StatusCode, Is.EqualTo(expectedStatus)); + if (expectedStatus == HttpStatusCode.OK) + { + Assert.That(await response.Content.ReadAsStringAsync(), Is.EqualTo("[]")); + } + } + + [Test] + public async Task Development_WithMultipleIdentityHeaders_Denies() + { + using var factory = new LaboratoriesHostFactory("Development"); + using var client = factory.CreateClient(); + using var request = CreateRequest("POST", null); + request.Headers.Add(TestUserHeader, new[] { "ana:Student", "marko:Teacher" }); + + using var response = await client.SendAsync(request); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task Development_IdentityDoesNotLeakBetweenRequests() + { + using var factory = new LaboratoriesHostFactory("Development"); + using var client = factory.CreateClient(); + using var teacherRequest = CreateRequest("POST", "marko:Teacher"); + using var teacherResponse = await client.SendAsync(teacherRequest); + Assert.That(teacherResponse.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); + + using var studentRequest = CreateRequest("POST", "ana:Student"); + using var studentResponse = await client.SendAsync(studentRequest); + Assert.That(studentResponse.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + + using var anonymousRequest = CreateRequest("POST", null); + using var anonymousResponse = await client.SendAsync(anonymousRequest); + Assert.That(anonymousResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [TestCase("Production", "GET")] + [TestCase("Production", "POST")] + [TestCase("Staging", "GET")] + [TestCase("Staging", "POST")] + public async Task OutsideDevelopment_TestTeacherHeaderDoesNotGrantAccess( + string environment, string method) + { + using var factory = new LaboratoriesHostFactory(environment); + using var client = factory.CreateClient(); + using var request = CreateRequest(method, "marko:Teacher"); + + using var response = await client.SendAsync(request); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + private static HttpRequestMessage CreateRequest(string method, string? testUser) + { + var request = new HttpRequestMessage(new HttpMethod(method), Route); + if (testUser is not null) + { + request.Headers.Add(TestUserHeader, testUser); + } + + return request; + } + + private sealed class LaboratoriesHostFactory(string environment) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(environment); + builder.ConfigureLogging(logging => logging.ClearProviders()); + } + } +} diff --git a/backend/Laboratories.Tests/LaboratoryTests.cs b/backend/Laboratories.Tests/LaboratoryTests.cs new file mode 100644 index 0000000..9340a30 --- /dev/null +++ b/backend/Laboratories.Tests/LaboratoryTests.cs @@ -0,0 +1,111 @@ +using Platform.Api.Modules.Laboratories.Domain; + +namespace Laboratories.Tests; + +public class LaboratoryTests +{ + [Test] + public void Create_GeneratesDistinctNonEmptyIds() + { + var first = new Laboratory("SQL injection"); + var second = new Laboratory("SQL injection"); + + Assert.That(first.Id, Is.Not.EqualTo(Guid.Empty)); + Assert.That(second.Id, Is.Not.EqualTo(Guid.Empty)); + Assert.That(second.Id, Is.Not.EqualTo(first.Id)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Create_WithMissingTitle_Throws(string? title) + { + Assert.Throws(() => new Laboratory(title!)); + } + + [Test] + public void Create_WithOverlongTitle_Throws() + { + Assert.Throws(() => + new Laboratory(new string('a', Laboratory.MaxTitleLength + 1))); + } + + [Test] + public void Create_WithMaximumLengthTitle_Accepts() + { + string title = new string('a', Laboratory.MaxTitleLength); + + var lab = new Laboratory(title); + + Assert.That(lab.Title, Is.EqualTo(title)); + } + + [Test] + public void Create_WithEmptyExistingId_Throws() + { + Assert.Throws(() => new Laboratory(Guid.Empty, "SQL injection")); + } + + [Test] + public void Rename_KeepsSameId() + { + Guid id = Guid.NewGuid(); + Laboratory lab = new Laboratory(id, "SQL injection"); + + lab.Rename("SQL injection - napredno"); + + Assert.That(lab.Id, Is.EqualTo(id)); + Assert.That(lab.Title, Is.EqualTo("SQL injection - napredno")); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Rename_WithMissingTitle_ThrowsAndPreservesState(string? title) + { + var lab = new Laboratory("Original title"); + Guid originalId = lab.Id; + + Assert.Throws(() => lab.Rename(title!)); + + Assert.That(lab.Id, Is.EqualTo(originalId)); + Assert.That(lab.Title, Is.EqualTo("Original title")); + } + + [Test] + public void Rename_WithOverlongTitle_ThrowsAndPreservesState() + { + var lab = new Laboratory("Original title"); + Guid originalId = lab.Id; + + Assert.Throws(() => + lab.Rename(new string('a', Laboratory.MaxTitleLength + 1))); + + Assert.That(lab.Id, Is.EqualTo(originalId)); + Assert.That(lab.Title, Is.EqualTo("Original title")); + } + + [Test] + public void Rename_WithMaximumLengthTitle_AcceptsAndKeepsId() + { + var lab = new Laboratory("Original title"); + Guid originalId = lab.Id; + string title = new string('a', Laboratory.MaxTitleLength); + + lab.Rename(title); + + Assert.That(lab.Id, Is.EqualTo(originalId)); + Assert.That(lab.Title, Is.EqualTo(title)); + } + + [Test] + public void CreateAndRename_TrimSurroundingWhitespace() + { + var lab = new Laboratory(" Original title "); + Assert.That(lab.Title, Is.EqualTo("Original title")); + + lab.Rename(" Updated title "); + + Assert.That(lab.Title, Is.EqualTo("Updated title")); + } +} diff --git a/backend/Platform.Api/Modules/Laboratories/Api/AccessCheckFilter.cs b/backend/Platform.Api/Modules/Laboratories/Api/AccessCheckFilter.cs new file mode 100644 index 0000000..4fcda6e --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Api/AccessCheckFilter.cs @@ -0,0 +1,37 @@ +using Platform.Api.Modules.Laboratories.Application; + +namespace Platform.Api.Modules.Laboratories.Api; + +// zahtev -> ko je? (IIdentityContext) -> sme li? (IAccessDecision) -> dozvoli / odbij +public static class AccessCheckFilter +{ + public static RouteHandlerBuilder RequireLabAccess(this RouteHandlerBuilder builder, string action) + { + return builder.AddEndpointFilter(async (context, next) => + { + HttpContext http = context.HttpContext; + IIdentityContext identity = http.RequestServices.GetRequiredService(); + IAccessDecision accessDecision = http.RequestServices.GetRequiredService(); + + AccessResult result = accessDecision.Decide(identity, action); + + // Dozvoljeno + if (result.Allowed) + { + return await next(context); + } + + // Odbijeno -> zapisi u log i vrati gresku + ILogger logger = http.RequestServices.GetRequiredService().CreateLogger("Laboratories.Access"); + logger.LogWarning("Pristup odbijen: radnja={Action} korisnik={UserId} razlog={Reason}", + action, identity.UserId ?? "(nepoznat)", result.Reason); + + if (result.Reason == DenyReason.Unauthenticated) + { + return Results.Problem(statusCode: 401, title: "Unauthenticated"); + } + + return Results.Problem(statusCode: 403, title: "Access denied"); + }); + } +} \ No newline at end of file diff --git a/backend/Platform.Api/Modules/Laboratories/Api/LaboratoriesEndpoints.cs b/backend/Platform.Api/Modules/Laboratories/Api/LaboratoriesEndpoints.cs new file mode 100644 index 0000000..2e637cb --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Api/LaboratoriesEndpoints.cs @@ -0,0 +1,19 @@ +using Platform.Api.Modules.Laboratories.Application; + +namespace Platform.Api.Modules.Laboratories.Api; + +public static class LaboratoriesEndpoints +{ + public static IEndpointRouteBuilder MapLaboratoriesEndpoints(this IEndpointRouteBuilder app) + { + RouteGroupBuilder group = app.MapGroup("/api/laboratories"); + + group.MapGet("/", () => Results.Ok(Array.Empty())) + .RequireLabAccess(LabActions.List); + + group.MapPost("/", () => Results.StatusCode(501)) + .RequireLabAccess(LabActions.Create); + + return app; + } +} \ No newline at end of file diff --git a/backend/Platform.Api/Modules/Laboratories/Application/IAccessDecision.cs b/backend/Platform.Api/Modules/Laboratories/Application/IAccessDecision.cs new file mode 100644 index 0000000..1cf5924 --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Application/IAccessDecision.cs @@ -0,0 +1,23 @@ +namespace Platform.Api.Modules.Laboratories.Application; + +// Ugovor iz specifikacije. Podrazumevano odbijanje. +// KT1: FakeAccessDecision, kasnije ACC modul (tim 3). + +public interface IAccessDecision +{ + AccessResult Decide(IIdentityContext subject, string action, string? resourceId = null); +} + +public enum DenyReason +{ + Unauthenticated, + UnknownAction, + NotPermitted +} + +public sealed record AccessResult(bool Allowed, DenyReason? Reason) +{ + public static AccessResult Allow() => new(true, null); + + public static AccessResult Deny(DenyReason reason) => new(false, reason); +} \ No newline at end of file diff --git a/backend/Platform.Api/Modules/Laboratories/Application/IIdentityContext.cs b/backend/Platform.Api/Modules/Laboratories/Application/IIdentityContext.cs new file mode 100644 index 0000000..d242c63 --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Application/IIdentityContext.cs @@ -0,0 +1,17 @@ +namespace Platform.Api.Modules.Laboratories.Application; + +// Ugovor iz specifikacije. KT1: fake, kasnije IDN modul (tim 18). +public interface IIdentityContext +{ + bool IsAuthenticated { get; } + string? UserId { get; } + PlatformRole? Role { get; } +} + +public enum PlatformRole +{ + Student, + Teacher, + Assistant, + Administrator +} \ No newline at end of file diff --git a/backend/Platform.Api/Modules/Laboratories/Application/LabActions.cs b/backend/Platform.Api/Modules/Laboratories/Application/LabActions.cs new file mode 100644 index 0000000..b6d6c3d --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Application/LabActions.cs @@ -0,0 +1,7 @@ +namespace Platform.Api.Modules.Laboratories.Application; + +public static class LabActions +{ + public const string List = "laboratory.list"; + public const string Create = "laboratory.create"; +} \ No newline at end of file diff --git a/backend/Platform.Api/Modules/Laboratories/Domain/Laboratory.cs b/backend/Platform.Api/Modules/Laboratories/Domain/Laboratory.cs new file mode 100644 index 0000000..83f6fe6 --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Domain/Laboratory.cs @@ -0,0 +1,46 @@ +namespace Platform.Api.Modules.Laboratories.Domain; + +public sealed class Laboratory +{ + public const int MaxTitleLength = 200; + + public Guid Id { get; } + public string Title { get; private set; } + + public Laboratory(string title) : this(Guid.NewGuid(), title) + { + } + + // Allows server-side reconstruction of a laboratory with its existing identity. + public Laboratory(Guid id, string title) + { + if (id == Guid.Empty) + { + throw new ArgumentException("Laboratory ID is required.", nameof(id)); + } + + Id = id; + Title = ValidateTitle(title); + } + + public void Rename(string title) + { + Title = ValidateTitle(title); + } + + private static string ValidateTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("Laboratory title is required.", nameof(title)); + } + + string normalized = title.Trim(); + if (normalized.Length > MaxTitleLength) + { + throw new ArgumentException($"Laboratory title must not exceed {MaxTitleLength} characters.", nameof(title)); + } + + return normalized; + } +} diff --git a/backend/Platform.Api/Modules/Laboratories/Infrastructure/AnonymousIdentityContext.cs b/backend/Platform.Api/Modules/Laboratories/Infrastructure/AnonymousIdentityContext.cs new file mode 100644 index 0000000..e243e2c --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Infrastructure/AnonymousIdentityContext.cs @@ -0,0 +1,14 @@ +using Platform.Api.Modules.Laboratories.Application; + +namespace Platform.Api.Modules.Laboratories.Infrastructure; + +// Koristi se VAN Development rezima dok ne stigne IDN modul: +// niko nije prepoznat (fail closed). +public sealed class AnonymousIdentityContext : IIdentityContext +{ + public bool IsAuthenticated => false; + + public string? UserId => null; + + public PlatformRole? Role => null; +} \ No newline at end of file diff --git a/backend/Platform.Api/Modules/Laboratories/Infrastructure/DenyAllAccessDecision.cs b/backend/Platform.Api/Modules/Laboratories/Infrastructure/DenyAllAccessDecision.cs new file mode 100644 index 0000000..beab344 --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Infrastructure/DenyAllAccessDecision.cs @@ -0,0 +1,18 @@ +using Platform.Api.Modules.Laboratories.Application; + +namespace Platform.Api.Modules.Laboratories.Infrastructure; + +// Koristi se VAN Development rezima dok ne stigne ACC modul: +// sve je odbijeno (fail closed). +public sealed class DenyAllAccessDecision : IAccessDecision +{ + public AccessResult Decide(IIdentityContext subject, string action, string? resourceId = null) + { + if (!subject.IsAuthenticated) + { + return AccessResult.Deny(DenyReason.Unauthenticated); + } + + return AccessResult.Deny(DenyReason.NotPermitted); + } +} \ No newline at end of file diff --git a/backend/Platform.Api/Modules/Laboratories/Infrastructure/FakeAccessDecision.cs b/backend/Platform.Api/Modules/Laboratories/Infrastructure/FakeAccessDecision.cs new file mode 100644 index 0000000..c921696 --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Infrastructure/FakeAccessDecision.cs @@ -0,0 +1,35 @@ +using Platform.Api.Modules.Laboratories.Application; + +namespace Platform.Api.Modules.Laboratories.Infrastructure; + +public sealed class FakeAccessDecision : IAccessDecision +{ + private static readonly Dictionary AllowedRoles = new() + { + [LabActions.List] = [PlatformRole.Student, PlatformRole.Teacher, PlatformRole.Assistant, PlatformRole.Administrator], + [LabActions.Create] = [PlatformRole.Teacher], + }; + + public AccessResult Decide(IIdentityContext subject, string action, string? resourceId = null) + { + // 1. Da li je korisnik poznat? + if (!subject.IsAuthenticated || subject.Role is null) + { + return AccessResult.Deny(DenyReason.Unauthenticated); + } + + // 2. Da li je radnja uopste definisana? + if (string.IsNullOrEmpty(action) || !AllowedRoles.TryGetValue(action, out PlatformRole[]? roles)) + { + return AccessResult.Deny(DenyReason.UnknownAction); + } + + // 3. Da li njegova uloga sme tu radnju? + if (!roles.Contains(subject.Role.Value)) + { + return AccessResult.Deny(DenyReason.NotPermitted); + } + + return AccessResult.Allow(); + } +} \ No newline at end of file diff --git a/backend/Platform.Api/Modules/Laboratories/Infrastructure/FakeIdentityContext.cs b/backend/Platform.Api/Modules/Laboratories/Infrastructure/FakeIdentityContext.cs new file mode 100644 index 0000000..6faa5f3 --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/Infrastructure/FakeIdentityContext.cs @@ -0,0 +1,61 @@ +using Platform.Api.Modules.Laboratories.Application; +using Microsoft.Extensions.Primitives; + +namespace Platform.Api.Modules.Laboratories.Infrastructure; + +public sealed class FakeIdentityContext : IIdentityContext +{ + public const string HeaderName = "X-Test-User"; + + private static readonly Dictionary KnownRoles = new() + { + ["Student"] = PlatformRole.Student, + ["Teacher"] = PlatformRole.Teacher, + ["Assistant"] = PlatformRole.Assistant, + ["Administrator"] = PlatformRole.Administrator + }; + + public FakeIdentityContext(IHttpContextAccessor httpContextAccessor) + { + IHeaderDictionary? headers = httpContextAccessor.HttpContext?.Request.Headers; + + if (headers is null || !headers.TryGetValue(HeaderName, out StringValues values) || values.Count != 1) + { + return; + } + + string? raw = values[0]; + if (string.IsNullOrWhiteSpace(raw)) + { + return; + } + + string[] parts = raw.Split(':'); + if (parts.Length != 2) + { + return; + } + + string userId = parts[0].Trim(); + string roleName = parts[1].Trim(); + + if (!IsValidUserId(userId) || !KnownRoles.TryGetValue(roleName, out PlatformRole role)) + { + return; + } + + IsAuthenticated = true; + UserId = userId; + Role = role; + } + + public bool IsAuthenticated { get; } + + public string? UserId { get; } + + public PlatformRole? Role { get; } + + private static bool IsValidUserId(string userId) => + userId.Length is >= 1 and <= 64 + && userId.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-'); +} \ No newline at end of file diff --git a/backend/Platform.Api/Modules/Laboratories/LaboratoriesModule.cs b/backend/Platform.Api/Modules/Laboratories/LaboratoriesModule.cs new file mode 100644 index 0000000..4f4ba6f --- /dev/null +++ b/backend/Platform.Api/Modules/Laboratories/LaboratoriesModule.cs @@ -0,0 +1,37 @@ +using Platform.Api.Modules.Laboratories.Api; +using Platform.Api.Modules.Laboratories.Application; +using Platform.Api.Modules.Laboratories.Infrastructure; + +namespace Platform.Api.Modules.Laboratories; + +public static class LaboratoriesModule +{ + // Registracija servisa modula + public static IServiceCollection AddLaboratories(this IServiceCollection services, IHostEnvironment environment) + { + services.AddHttpContextAccessor(); + + if (environment.IsDevelopment()) + { + // FAKE implementacije - samo u Development rezimu. + // FakeIdentityContext - IDN modul (tim 18) + // FakeAccessDecision - ACC modul (tim 3) + services.AddScoped(); + services.AddSingleton(); + } + else + { + // Niko nije prepoznat, sve je odbijeno. + services.AddScoped(); + services.AddSingleton(); + } + + return services; + } + + public static IEndpointRouteBuilder MapLaboratories(this IEndpointRouteBuilder app) + { + app.MapLaboratoriesEndpoints(); + return app; + } +} \ No newline at end of file diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index b5ea3df..535b96f 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -1,3 +1,4 @@ +using Platform.Api.Modules.Laboratories; using Platform.Access.Composition; using Platform.Access.Contracts; @@ -61,6 +62,8 @@ builder.Services.AddSingleton(); } +builder.Services.AddLaboratories(builder.Environment); + var app = builder.Build(); // IDN-01: politike i endpoint za kreiranje naloga @@ -95,6 +98,7 @@ app.UseMiddleware(); app.MapControllers(); +app.MapLaboratories(); if (app.Environment.IsDevelopment()) { diff --git a/backend/SecurityLab.slnx b/backend/SecurityLab.slnx index e0cdbad..98647a4 100644 --- a/backend/SecurityLab.slnx +++ b/backend/SecurityLab.slnx @@ -1,4 +1,5 @@ + diff --git a/docs/components/LAB-01-LAB-07.md b/docs/components/LAB-01-LAB-07.md new file mode 100644 index 0000000..6ca3bd6 --- /dev/null +++ b/docs/components/LAB-01-LAB-07.md @@ -0,0 +1,202 @@ +# LAB-01 / LAB-07: KT1 kostur modula Laboratories + +**Grana:** `feature/LAB-01-skeleton` + +**Dodeljeni zahtevi:** LAB-01 (trajni identitet laboratorije), LAB-07 (lokalizacija i pristupacne varijante). + +## Odgovornosti i trenutno stanje + +Ovaj dokument opisuje trenutno implementiran kostur. Ne predstavlja dokaz zavrsenog LAB-01 ili LAB-07. + +- `Domain/Laboratory` cuva trajni `Id` i validiran `Title`. Konstruktor za novu laboratoriju generise `Guid` na serveru; konstruktor sa postojecim ID-jem namenjen je rekonstrukciji na serveru. Buduci API ne sme mapirati klijentski ID u taj konstruktor. +- `Rename()` cuva ID. Naslov je obavezan, normalizuje se uklanjanjem okolnih razmaka i ima najvise 200 znakova. +- `Application` definise lokalne portove `IIdentityContext`, `IAccessDecision` i poznate radnje `laboratory.list` i `laboratory.create`. +- `Api` sadrzi pocetne endpoint-e i serverski filter pristupa. Domen jos nije povezan sa endpoint-ima niti skladistem. +- `Infrastructure` sadrzi razvojne fake implementacije i implementacije koje odbijaju pristup van Development okruzenja. +- `LaboratoriesModule` registruje servise kroz `AddLaboratories` i rute kroz `MapLaboratories`, uz ocuvane registracije ostalih modula u `Program.cs`. +- `backend/Laboratories.Tests` je ukljucen u zajednicki `backend/SecurityLab.slnx`. + +Nisu implementirani: stvarno kreiranje/citanje/preimenovanje preko API-ja, repozitorijum, veza sa kursom, revizije, prevodi, evaluator i frontend laboratorije. `Status`, `CreatedBy` i `CreatedAt` iz timskog plana takodje ostaju naredni korak domena. + +## Dijagram arhitekture + +Puna strelica oznacava postojecu vezu, isprekidana planiranu integraciju. + +```mermaid +flowchart TB + HOST["Platform.Api / Program.cs"] --> MOD["LaboratoriesModule"] + MOD --> DI["AddLaboratories: registracija servisa"] + MOD --> ROUTES["MapLaboratories: endpointi"] + ROUTES --> FILTER["AccessCheckFilter"] + FILTER --> ID["Lokalni IIdentityContext"] + FILTER --> ACC["Lokalni IAccessDecision"] + DI --> DEV["Development: FakeIdentityContext i FakeAccessDecision"] + DI --> SAFE["Ostala okruzenja: AnonymousIdentityContext i DenyAllAccessDecision"] + DEV --> ID + DEV --> ACC + SAFE --> ID + SAFE --> ACC + TEST["Laboratories.Tests"] --> DOMAIN["Domain / Laboratory"] + TEST --> DEV + ROUTES -.-> SVC["Buduci aplikacioni servis"] + SVC -.-> DOMAIN + SVC -.-> STORE["Buduci port repozitorijuma"] + ID -.-> IDN["Planirani Identity adapter"] + ACC -.-> ACCESS["Planirani Platform.Access adapter"] +``` + +Lokalni interfejsi su portovi ovog modula, a ne nove zamene za zajednicke ugovore. `Platform.Access.Contracts.IAccessDecision` koristi asinhroni `AccessRequest`; Identity ima odvojene ugovore za pozivaoca zahteva i za pronalazenje naloga. Potrebni su eksplicitni adapteri, mapiranje identifikatora i konteksta kursa. Samo podudaranje imena interfejsa nije integracija. + +## DFD: trenutni zahtev i granica poverenja + +```mermaid +flowchart LR + CLIENT["Klijent / nepouzdana zona"] -->|"HTTP zahtev; u razvoju X-Test-User"| P1 + subgraph SERVER["Serverska granica poverenja"] + P1["1. Procitaj identitet"] -->|"Identitet ili anonimno"| P2["2. Proveri poznatu radnju i dozvolu"] + P2 -->|"Odbijeno"| DENY["401 ili 403"] + P2 -->|"Dozvoljeno"| P3["3. Pocetni endpoint"] + P3 -->|"GET"| LIST["200 i prazna lista"] + P3 -->|"POST"| STUB["501: kreiranje jos nije implementirano"] + P2 -->|"Odbijena radnja, korisnik i razlog"| LOG[("Serverski log")] + end + DENY --> CLIENT + LIST --> CLIENT + STUB --> CLIENT +``` + +Trenutno nema skladista laboratorija niti upisa u bazu; serverski log je jedino prikazano odrediste zapisa. Domen se proverava direktno kroz testove. U buducem toku aplikacioni servis posle autorizacije validira podatke, menja domen i upisuje kroz repozitorijum. + +`X-Test-User` je simulacija, ne dokaz stvarnog identiteta: klijent moze sam poslati `marko:Teacher`. Fake prihvata sintakticki ispravan identifikator i poznatu ulogu, bez provere registra korisnika. Nepostojece ime sa poznatom ulogom zato nije dokazano odbijeno. Bez zaglavlja, sa neispravnim formatom ili nepoznatom ulogom identitet ostaje anoniman. Test `Decide_WhenUserIsUnknown_Denies` proverava anonimni subjekat, ne pretragu korisnickih naloga. Fake se registruje samo u Development okruzenju; van njega pristup laboratorijama se odbija. + +## Granice prema drugim timovima + +| Modul / zahtev | Vlasnistvo i dogovor za povezivanje | +| --- | --- | +| Tim 11, AUT-01 / AUT-06 | `Platform.Authoring` poseduje nacrte, njihove izmene i kopiranje sablona. LAB-01 poseduje trajni identitet laboratorije. Planirana veza nacrta sa `LaboratoryId` mora biti dogovorena sa timom 11; trenutno je nema. Ne uvoditi drugi authoring servis u LAB modul. | +| Tim 11, verzije i naslov | `LabDraft.Version` je kontrola konkurentnih izmena, ne broj objavljene revizije. Njihov naslov sadrzaja ima limit 120, a trenutni LAB naslov 200 znakova. Pre povezivanja dogovoriti jedinstvena pravila prenosa i izvor naslova; ne kopirati tekst automatski u dva nezavisna modela. | +| LAB-02, PR #43 | `Platform.LabMetadata.Laboratory` takodje sadrzi ID i preimenovanje. Pre integracije usaglasiti zajednicki model ili vezu metapodataka preko trajnog `LaboratoryId`; ne stvarati drugi identitet za istu laboratoriju. PR nije ukljucen u ovu granu. | +| LAB-06, PR #41 | Preduslovi treba da referenciraju trajni `LaboratoryId`. Ovaj modul ne preuzima proveru zavrsenosti i ciklusa. PR nije ukljucen u ovu granu. | +| Identity i Access | Koristiti njihove javne ugovore kroz adaptere. Razvojna globalna uloga nije zamena za proveru clanstva i prava nad konkretnim kursom/resursom. Ne menjati registracije drugih timova da bi fake laboratorije radio. | +| REV | Izdavanje broja i nepromenljivog snapshot-a pripada objavljivanju (REV-01). Eventualna minimalna revizija za KT1 mora biti jasno oznacena kao demonstraciona dok se ne dogovori zajednicki ugovor. | +| AUT-04, PR #44 | LAB-07 bira prevod dozvoljenog javnog sadrzaja. Ne preuzima kontrolu pristupa evaluator kljucevima. PR nije ukljucen u ovu granu. | +| LAB-07 | Buduci sr/en tekst referencira isti `RevisionId` i isti evaluator. Promena jezika ne stvara novu reviziju niti menja ocenjivanje. Implementacija jos ne postoji. | + +Navedene granice su predlog integracije na osnovu postojeceg koda, a ne potvrda saglasnosti drugih timova. Njihovi fajlovi nisu menjani radi ove dokumentacije. + +## Bezbednosne provere i ogranicenja + +- Filter radi na serveru pre endpoint-a. Anonimni identitet dobija 401, nedozvoljena radnja 403. +- Nepoznata radnja se odbija; student ne moze da prodje proveru kreiranja, nastavnik moze da dodje do jos neimplementiranog POST-a (501). +- `LaboratoryTests` proverava generisanje ID-ja, ocuvanje ID-ja pri preimenovanju, prazne/preduge naslove, granicnu duzinu i ocuvanje stanja pri odbijenoj promeni. +- `LaboratoriesHttpTests` pokrece stvarni API host u memoriji: proverava 200/401/403/501, odbijanje neispravnog i visestrukog identitetskog zaglavlja, izolaciju identiteta izmedju zahteva i odbijanje test nastavnika u Production i Staging okruzenju. Host koristi postojece registracije modula; testovi ne zamenjuju LAB provere prava. +- Kontrola pristupa trenutno je razvojna simulacija, bez stvarne sesije, kursa, vlasnistva ili provere statusa naloga. Testovi tog stvarnog integracionog toka ostaju naredni korak. +- Putanja kostura je `/api/laboratories/`. Timski plan za API navodi `/api/labs`; kanonsku putanju treba usaglasiti pre dodavanja narednih endpoint-a i frontenda. + +## Pokretanje i kratka demonstracija + +Potreban je .NET SDK 10. Komande se pokrecu iz korena repozitorijuma. Za ovaj kostur nije potrebna baza laboratorija niti Docker. Ostali moduli mogu zahtevati svoju konfiguraciju. + +```powershell +dotnet build backend/SecurityLab.slnx --configuration Release +dotnet test backend/SecurityLab.slnx --configuration Release --no-build + +$env:ASPNETCORE_ENVIRONMENT = 'Development' +dotnet run --project backend/Platform.Api/Platform.Api.csproj --no-launch-profile --urls http://localhost:5080 +``` + +U drugom terminalu, uz slobodan port 5080: + +```powershell +# Bez identiteta: 401 +curl.exe -i http://localhost:5080/api/laboratories/ + +# Razvojni student cita pocetnu praznu listu: 200 +curl.exe -i -H "X-Test-User: ana:Student" http://localhost:5080/api/laboratories/ + +# Razvojni student ne sme da kreira: 403 +curl.exe -i -X POST -H "X-Test-User: ana:Student" http://localhost:5080/api/laboratories/ + +# Razvojni nastavnik prolazi proveru, ali kreiranje jos ne postoji: 501 +curl.exe -i -X POST -H "X-Test-User: marko:Teacher" http://localhost:5080/api/laboratories/ +``` + +### Izvrsena lokalna provera + +Provera je izvrsena 3. novembra 2026. nad trenutnim lokalnim izmenama, u Development okruzenju, na `http://127.0.0.1:5080`, sa .NET SDK 10.0.403. API je pokrenut iz Release builda sa `--no-build --no-restore --no-launch-profile`. Koriscena je postojeca razvojna konfiguracija, bez `X-Stub-User` zaglavlja i bez konekcije sa bazom laboratorija. API je posle provere zaustavljen. + +| Zahtev | X-Test-User | Dobijeno | Tumacenje | +| --- | --- | --- | --- | +| GET `/api/health` | nema | 200 | API je dostupan. | +| GET `/api/laboratories/` | nema | 401 | Anonimni zahtev je odbijen. | +| GET `/api/laboratories/` | `ana:Student` | 200, `[]` | Pocetni prikaz, bez podataka iz skladista. | +| POST `/api/laboratories/` | `ana:Student` | 403 | Student je odbijen na serveru. | +| POST `/api/laboratories/` | `marko:Teacher` | 501 | Provera prava prolazi, kreiranje nije implementirano. | +| GET `/api/laboratories/` | `ana:Unknown` | 401 | Nepoznata uloga je odbijena. | +| GET `/api/laboratories/` | `invalid` | 401 | Neispravan format identiteta je odbijen. | +| POST `/api/laboratories/` | `not-a-real-account:Teacher` | 501 | Potvrdjeno ogranicenje fake-a: ne proverava postojanje naloga. | + +Frontend je proveren komandama `npm ci --no-audit --no-fund` i `npm run build` iz `frontend/`: TypeScript provera i Vite build su prosli. Ovo nije dokaz funkcionalnog LAB frontenda: taj deo jos nije implementiran, niti je radjena provera pristupacnosti u browseru. + +HTTP provere su rucni lokalni smoke testovi, nisu automatizovani integracioni testovi niti GitHub CI rezultat. Zajednicki middleware drugih modula i njihova razvojna konfiguracija mogu uticati na zahteve u drugim okruzenjima. + +## Konkretan predlog Identity/Access integracije + +Ovo je predlog naredne izmene, jos nije implementiran niti usaglasen sa vlasnicima ugovora. Postojeci kod Identity/Access modula ostaje nepromenjen. + +### Postojeci ugovori koje treba koristiti + +| Potreba | Postojeci ugovor | Ogranicenje trenutnog stanja | +| --- | --- | --- | +| Identitet pozivaoca | `Platform.Api.Services.Identity.IIdentityContext.Current` | Development koristi `X-Stub-User`, ostala okruzenja anonimnu implementaciju. To jos nije stvarna autentifikovana sesija. | +| Postojanje i status naloga | `Platform.Identity.Contracts.IIdentityContext.FindByIdAsync(Guid, CancellationToken)` | Deklaracija postoji; u pregledanom kodu nije nadjena implementacija ni DI registracija ovog ugovora. Samo registrovanje Identity modula ne obezbedjuje ovaj lookup. | +| Odluka o pristupu | `Platform.Access.Contracts.IAccessDecision.DecideAsync(AccessRequest, CancellationToken)` | Centralni servis vec odbija anonimnog/neaktivnog subjekta, nepoznatu radnju, nedostajucu politiku i gresku politike. | +| Kontekst zahteva | `AccessRequest(Subject, Action, Resource, CorrelationId)` | `SubjectRef` ima ID i status, bez uloge. `ResourceRef` ima samo tip i ID, bez posebnog `CourseId` polja. | +| Politika i registracija | `IAccessPolicy`, `InMemoryActionCatalog.Register`, `InMemoryAccessPolicyRegistry.Register` | Registracija ide jednom pri pokretanju, kao postojeci `RegisterIdentityAccess`; oba registra odbijaju duplikate. | + +`IRequestSubjectResolver` trenutno koristi razvojni ID iz konfiguracije ili vraca anonimnog subjekta; ne daje ulogu niti proveru naloga. Zato se ne koristi kao dovoljan dokaz stvarnog identiteta. Takodje, sinhroni LAB port `Decide` ne treba povezivati sa asinhronim Access servisom preko `.Result` ili `.GetAwaiter().GetResult()`. + +### Predlozene izmene samo unutar LAB modula + +1. Dodati scoped adapter u LAB `Infrastructure` koji cita pozivaoca kroz Identity ugovor, zatim asinhrono proverava postojanje i status naloga. Prazan/nepoznat ID, neautentifikovana sesija ili nedostupan podatak ne smeju dati dozvolu. Status mapirati eksplicitno (`Active`, `Blocked`, `Archived`), bez cast-a izmedju enum-a i bez podrazumevanog `Active`. +2. Prevesti lokalni LAB port i filter na asinhroni tok i proslediti `HttpContext.RequestAborted`. Uloga iz klijentskog zaglavlja se ne prenosi u stvarnu politiku. Adapter poziva postojeci `Platform.Access.Contracts.IAccessDecision` sa korelacionim ID-jem iz `HttpContext.TraceIdentifier`. +3. Dodati LAB implementacije `IAccessPolicy` za `laboratory.list` i `laboratory.create`. Jednokratnu registraciju radnji/politika obaviti kroz LAB composition metodu posle `builder.Build()`, uz jednu dodatnu liniju u hostu. Ne menjati centralne Access registre, postojece politike ili njihove registracije. +4. Za listanje i kreiranje vezano za kurs koristiti `ResourceRef("course", courseId)` i serverski izvor clanstva. Za citanje/preimenovanje postojece laboratorije koristiti `ResourceRef("laboratory", laboratoryId)`; politika preko javnog porta pronalazi kurs i proverava prava. Klijent moze zahtevati ID kursa/resursa, ali server proverava da taj kontekst stvarno pripada resursu i pozivaocu. +5. Ako ugovor za clanstvo/kontekst kursa jos nije dostupan, LAB politika ostaje odbijajuca. Ne dozvoljavati svim korisnicima samo zato sto je nalog aktivan. Naziv i potpis porta za clanstvo usaglasiti sa Access/Course timom; postojeci ugovori nemaju taj podatak. +6. HTTP mapiranje: odsutan/neprihvatljiv identitet vraca 401; autentifikovan pozivalac bez dozvole 403. Detaljne Access razloge zadrzati na serveru. Gresku infrastrukture ne prikazivati kao uspesnu autorizaciju; bezbedan odgovor i eventualni 503 usaglasiti sa API pravilima. +7. Fake implementacije izdvojiti iza eksplicitne razvojne konfiguracije, uz zabranu njihovog ukljucivanja van Development okruzenja. Integracioni rezim nema automatski povratak na fake ako nedostaje pravi servis. Dok zavisnosti nisu dostupne, trenutni KT1 demo ostaje jasno oznacen kao simulacija. + +Centralni Access servis je singleton. Registrovana politika ne sme direktno zadrzati scoped Identity/DbContext ili trenutni HTTP zahtev. Lifetime buduce politike i nacina pristupa clanstvo/resurs podacima treba usaglasiti sa Access timom pre implementacije; ukljuciti DI scope validation test. Ne menjati centralni servis u scoped niti koristiti root provider za scoped servise bez tog dogovora. + +### Predlozeni tok posle integracije + +```mermaid +flowchart LR + REQ["HTTP zahtev"] --> CALLER["Identity: provereni pozivalac"] + CALLER --> ACCOUNT["Identity: nalog i status"] + ACCOUNT --> ADAPTER["Scoped LAB adapter"] + ADAPTER --> ACCESS["Centralni IAccessDecision"] + ACCESS --> POLICY["LAB politika za radnju"] + POLICY --> CONTEXT["Serverski port: kurs, clanstvo i resurs"] + POLICY --> RESULT["Dozvoli ili odbij"] + RESULT --> ENDPOINT["LAB filter i endpoint"] +``` + +Sve veze u ovom dijagramu predstavljaju predlog, ne trenutno izvrsavanje. + +### Kriterijumi pre ukljucivanja adaptera + +- Identity tim potvrdi izvor pozivaoca, proveru sesije/statusa i obezbedi account lookup implementaciju ili dogovoreni ekvivalent. +- Access/Course tim potvrdi izvor clanstva, resource kontekst i lifetime politika. Bez tih podataka integracija ostaje odbijajuca. +- Automatizovani HTTP testovi dokazuju: anonimni/nepoznat nalog se odbija; blokiran/arhiviran nalog se odbija; student ne kreira; nastavnik dozvoljenog kursa moze; nastavnik drugog kursa ne moze; nepoznata radnja i nedostajuca politika se odbijaju. +- Dodatno proveriti: pokusaj slanja `X-Test-User` u integracionom rezimu ne daje privilegije, otkazivanje zahteva se propagira, nema dvostruke registracije radnji, DI scope validation prolazi i testovi drugih timova ostaju zeleni. +- Endpoint za stvarno kreiranje ima svoj pozitivan kriterijum (201 i serverski ID); danasnji 501 nije taj dokaz. + +## Dokazi rada i sledeci koraci + +- Kod: `backend/Platform.Api/Modules/Laboratories/`, testovi: `backend/Laboratories.Tests/`. +- Zajednicki CI koristi `backend/SecurityLab.slnx`; LAB test projekat je sada ukljucen. +- Lokalna provera 3. novembra 2026, .NET SDK 10.0.403: `dotnet test backend/SecurityLab.slnx --configuration Release` je prosao. Izvrseno je 199 testova: Laboratories 31 (ukljucujuci 13 HTTP slucajeva), Authoring 55, Audit 71 i Platform.Tests 42; nema neuspesnih ili preskocenih testova. To je rezultat trenutnih lokalnih izmena, ne GitHub CI provere. +- Individualni doprinos prikazati stvarnim commit-ima/PR-om svakog clana. Ovaj dokument ne pripisuje nepotvrdjeno autorstvo. +- Pre push-a izvrsiti build/test, proveriti diff i dogovoriti preostale integracione ugovore. PR jos nije otvoren. +- Naredno: adapteri i HTTP testovi stvarnog identiteta i konteksta kursa, usaglasavanje modela sa LAB-02 i timom 11, repozitorijum, revizije i LAB-07 prevodi/pristupacnost.