From 3a925b9b0f9ead5b0d38d7c54b882b702ca23d66 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Thu, 14 May 2026 04:17:55 +0300 Subject: [PATCH] refactor: remove obsolete resource download and installer endpoints - Deleted the `POST /resources/get/{dataFolder?}` and `GET /resources/get-installer` endpoints as part of the architectural shift towards simplified resource management. - Removed associated methods and configurations, including `ResourcesService.GetEncryptedResource`, `ResourcesService.GetInstaller`, and related properties in `ResourcesConfig`. - Cleaned up environment variables and configuration files to reflect the removal of installer-related settings. - Eliminated the `GetResourceRequest` DTO and its validator, along with the `WrongResourceName` error code. - Updated documentation to clarify the changes in resource handling and the retirement of per-user file encryption. Co-authored-by: Cursor --- .env.example | 2 - Azaion.AdminApi.sln | 11 +- Azaion.AdminApi/Program.cs | 42 ----- Azaion.AdminApi/appsettings.json | 4 +- Azaion.Common/BusinessException.cs | 3 - Azaion.Common/Configs/ResourcesConfig.cs | 4 +- Azaion.Common/Extensions/StreamExtensions.cs | 15 -- Azaion.Common/Requests/GetResourceRequest.cs | 25 --- Azaion.Services/ResourcesService.cs | 25 --- Azaion.Services/Security.cs | 51 ------ Azaion.Test/Azaion.Test.csproj | 23 --- Azaion.Test/SecurityTest.cs | 110 ------------ _docs/02_document/architecture.md | 22 +-- .../common-helpers/01_helper_extensions.md | 3 +- .../02_helper_business_exception.md | 10 +- .../components/01_data_layer/description.md | 5 +- .../03_auth_and_security/description.md | 28 ++- .../04_resource_management/description.md | 45 ++--- .../components/05_admin_api/description.md | 4 +- .../deployment/environment_strategy.md | 4 +- _docs/02_document/diagrams/components.md | 9 +- .../flows/flow_encrypted_resource_download.md | 37 ++-- _docs/02_document/module-layout.md | 13 +- .../02_document/modules/admin_api_program.md | 26 +-- .../modules/common_business_exception.md | 5 +- .../common_configs_resources_config.md | 10 +- .../modules/common_entities_user.md | 2 +- .../common_extensions_stream_extensions.md | 34 ---- .../modules/common_requests_get_resource.md | 46 ----- .../modules/services_auth_service.md | 2 +- .../modules/services_resources_service.md | 18 +- .../02_document/modules/services_security.md | 31 ++-- .../modules/services_user_service.md | 4 +- .../02_document/modules/test_security_test.md | 45 ----- .../modules/test_user_service_test.md | 39 ---- _docs/02_document/system-flows.md | 84 ++------- _docs/02_document/tests/blackbox-tests.md | 72 ++++---- _docs/02_document/tests/security-tests.md | 30 ++-- .../02_document/tests/traceability-matrix.md | 21 ++- _docs/02_tasks/_dependencies_table.md | 22 ++- .../todo/AZ-531_refresh_token_flow.md | 84 +++++++++ .../todo/AZ-532_asymmetric_signing_jwks.md | 81 +++++++++ .../02_tasks/todo/AZ-533_mission_token_uav.md | 102 +++++++++++ _docs/02_tasks/todo/AZ-534_totp_2fa_login.md | 93 ++++++++++ .../02_tasks/todo/AZ-535_logout_revocation.md | 82 +++++++++ .../todo/AZ-536_argon2id_password_hashing.md | 93 ++++++++++ .../todo/AZ-537_login_rate_limit_lockout.md | 99 ++++++++++ .../todo/AZ-538_cors_https_only_hsts.md | 95 ++++++++++ _docs/04_deploy/observability.md | 5 +- .../04_deploy/reports/deploy_status_report.md | 2 - _docs/05_security/owasp_review.md | 13 +- _docs/06_metrics/retro_2026-05-13.md | 169 ++++++++++++++++++ _docs/06_metrics/structure_2026-05-13.md | 54 ++++++ _docs/LESSONS.md | 19 ++ _docs/_autodev_state.md | 6 +- docker-compose.test.yml | 2 - e2e/Azaion.E2E/Tests/ResourceTests.cs | 134 -------------- e2e/Azaion.E2E/Tests/SecurityTests.cs | 61 ------- secrets/production.public.env | 2 - secrets/staging.public.env | 2 - 60 files changed, 1202 insertions(+), 982 deletions(-) delete mode 100644 Azaion.Common/Extensions/StreamExtensions.cs delete mode 100644 Azaion.Common/Requests/GetResourceRequest.cs delete mode 100644 Azaion.Test/Azaion.Test.csproj delete mode 100644 Azaion.Test/SecurityTest.cs delete mode 100644 _docs/02_document/modules/common_extensions_stream_extensions.md delete mode 100644 _docs/02_document/modules/common_requests_get_resource.md delete mode 100644 _docs/02_document/modules/test_security_test.md delete mode 100644 _docs/02_document/modules/test_user_service_test.md create mode 100644 _docs/02_tasks/todo/AZ-531_refresh_token_flow.md create mode 100644 _docs/02_tasks/todo/AZ-532_asymmetric_signing_jwks.md create mode 100644 _docs/02_tasks/todo/AZ-533_mission_token_uav.md create mode 100644 _docs/02_tasks/todo/AZ-534_totp_2fa_login.md create mode 100644 _docs/02_tasks/todo/AZ-535_logout_revocation.md create mode 100644 _docs/02_tasks/todo/AZ-536_argon2id_password_hashing.md create mode 100644 _docs/02_tasks/todo/AZ-537_login_rate_limit_lockout.md create mode 100644 _docs/02_tasks/todo/AZ-538_cors_https_only_hsts.md create mode 100644 _docs/06_metrics/retro_2026-05-13.md create mode 100644 _docs/06_metrics/structure_2026-05-13.md create mode 100644 _docs/LESSONS.md diff --git a/.env.example b/.env.example index dfc5323..7c07662 100644 --- a/.env.example +++ b/.env.example @@ -22,8 +22,6 @@ ASPNETCORE_JwtConfig__TokenLifetimeHours=4 # ---------- Resource storage (filesystem) ----------------------------------- ASPNETCORE_ResourcesConfig__ResourcesFolder=Content -ASPNETCORE_ResourcesConfig__SuiteInstallerFolder=suite -ASPNETCORE_ResourcesConfig__SuiteStageInstallerFolder=suite-stage # ---------- Container build / image label ------------------------------------ # Injected at build time as --build-arg CI_COMMIT_SHA=… by Woodpecker. diff --git a/Azaion.AdminApi.sln b/Azaion.AdminApi.sln index 31ce6ae..32d5784 100644 --- a/Azaion.AdminApi.sln +++ b/Azaion.AdminApi.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.AdminApi", "Azaion.AdminApi\Azaion.AdminApi.csproj", "{03A56CF2-A57F-4631-8454-C08B804B8903}" EndProject @@ -6,8 +6,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Common", "Azaion.Com EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Services", "Azaion.Services\Azaion.Services.csproj", "{07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azaion.Test", "Azaion.Test\Azaion.Test.csproj", "{2F4F0EA9-0645-4917-8D21-F317E815EB9E}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{49FBE419-D2FA-4D7C-8419-D3AD5B44DD58}" ProjectSection(SolutionItems) = preProject Dockerfile = Dockerfile @@ -32,9 +30,8 @@ Global {07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Debug|Any CPU.Build.0 = Debug|Any CPU {07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Release|Any CPU.ActiveCfg = Release|Any CPU {07CFFA74-A1ED-43F9-9CD4-5A09B320EF44}.Release|Any CPU.Build.0 = Release|Any CPU - {2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2F4F0EA9-0645-4917-8D21-F317E815EB9E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/Azaion.AdminApi/Program.cs b/Azaion.AdminApi/Program.cs index 620493d..d429a8e 100644 --- a/Azaion.AdminApi/Program.cs +++ b/Azaion.AdminApi/Program.cs @@ -261,48 +261,6 @@ app.MapPost("/resources/clear/{dataFolder?}", .RequireAuthorization(apiAdminPolicy) .WithSummary("Clear folder"); -app.MapPost("/resources/get/{dataFolder?}", //Need to have POST method for secure password - async ([FromBody]GetResourceRequest request, [FromRoute]string? dataFolder, IAuthService authService, - IResourcesService resourcesService, CancellationToken ct) => - { - var user = await authService.GetCurrentUser(); - if (user == null) - throw new UnauthorizedAccessException(); - - var key = Security.GetApiEncryptionKey(user.Email, request.Password); - var stream = await resourcesService.GetEncryptedResource(dataFolder, request.FileName, key, ct); - - return Results.File(stream, "application/octet-stream", request.FileName); - }).RequireAuthorization() - .WithSummary("Gets encrypted by user's Password resource. POST method for secure password"); - -app.MapGet("/resources/get-installer", - async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) => - { - var user = await authService.GetCurrentUser(); - if (user == null) - throw new UnauthorizedAccessException(); - var (name, stream) = resourcesService.GetInstaller(isStage: false); - if (stream == null) - throw new FileNotFoundException("Installer file was not found!"); - return Results.File(stream, "application/octet-stream", name); - }).RequireAuthorization() - .WithSummary("Gets latest installer"); - -app.MapGet("/resources/get-installer/stage", - async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) => - { - var user = await authService.GetCurrentUser(); - if (user == null) - throw new UnauthorizedAccessException(); - var (name, stream) = resourcesService.GetInstaller(isStage: true); - if (stream == null) - throw new FileNotFoundException("Installer file was not found!"); - return Results.File(stream, "application/octet-stream", name); - }).RequireAuthorization() - .WithSummary("Gets latest installer"); - - app.MapPost("/classes", async (CreateDetectionClassRequest request, IValidator validator, IDetectionClassService detectionClassService, CancellationToken ct) => diff --git a/Azaion.AdminApi/appsettings.json b/Azaion.AdminApi/appsettings.json index 95d9ed7..1a02728 100644 --- a/Azaion.AdminApi/appsettings.json +++ b/Azaion.AdminApi/appsettings.json @@ -7,9 +7,7 @@ }, "AllowedHosts": "*", "ResourcesConfig": { - "ResourcesFolder": "Content", - "SuiteInstallerFolder": "suite", - "SuiteStageInstallerFolder": "suite-stage" + "ResourcesFolder": "Content" }, "JwtConfig": { "Issuer": "AzaionApi", diff --git a/Azaion.Common/BusinessException.cs b/Azaion.Common/BusinessException.cs index ddd59b3..db0ffcd 100644 --- a/Azaion.Common/BusinessException.cs +++ b/Azaion.Common/BusinessException.cs @@ -39,9 +39,6 @@ public enum ExceptionEnum [Description("User account is disabled.")] UserDisabled = 38, - [Description("Wrong resource file name.")] - WrongResourceName = 50, - [Description("No file provided.")] NoFileProvided = 60, } \ No newline at end of file diff --git a/Azaion.Common/Configs/ResourcesConfig.cs b/Azaion.Common/Configs/ResourcesConfig.cs index ecad0f2..6910bc6 100644 --- a/Azaion.Common/Configs/ResourcesConfig.cs +++ b/Azaion.Common/Configs/ResourcesConfig.cs @@ -3,6 +3,4 @@ namespace Azaion.Common.Configs; public class ResourcesConfig { public string ResourcesFolder { get; set; } = null!; - public string SuiteInstallerFolder { get; set; } = null!; - public string SuiteStageInstallerFolder { get; set; } = null!; -} \ No newline at end of file +} diff --git a/Azaion.Common/Extensions/StreamExtensions.cs b/Azaion.Common/Extensions/StreamExtensions.cs deleted file mode 100644 index 3fdd47f..0000000 --- a/Azaion.Common/Extensions/StreamExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text; - -namespace Azaion.Common.Extensions; - -public static class StreamExtensions -{ - public static string ConvertToString(this Stream stream) - { - stream.Position = 0; - using var reader = new StreamReader(stream, Encoding.UTF8); - var result = reader.ReadToEnd(); - stream.Position = 0; - return result; - } -} \ No newline at end of file diff --git a/Azaion.Common/Requests/GetResourceRequest.cs b/Azaion.Common/Requests/GetResourceRequest.cs deleted file mode 100644 index 6da14aa..0000000 --- a/Azaion.Common/Requests/GetResourceRequest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FluentValidation; - -namespace Azaion.Common.Requests; - -public class GetResourceRequest -{ - public string Password { get; set; } = null!; - public string FileName { get; set; } = null!; -} - -public class GetResourceRequestValidator : AbstractValidator -{ - public GetResourceRequestValidator() - { - RuleFor(r => r.Password) - .MinimumLength(8) - .WithErrorCode(nameof(ExceptionEnum.PasswordLengthIncorrect)) - .WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.PasswordLengthIncorrect)); - - RuleFor(r => r.FileName) - .NotEmpty() - .WithErrorCode(nameof(ExceptionEnum.WrongResourceName)) - .WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.WrongResourceName)); - } -} diff --git a/Azaion.Services/ResourcesService.cs b/Azaion.Services/ResourcesService.cs index 60bef6b..9ec84ca 100644 --- a/Azaion.Services/ResourcesService.cs +++ b/Azaion.Services/ResourcesService.cs @@ -8,8 +8,6 @@ namespace Azaion.Services; public interface IResourcesService { - (string?, Stream?) GetInstaller(bool isStage); - Task GetEncryptedResource(string? dataFolder, string fileName, string key, CancellationToken cancellationToken = default); Task SaveResource(string? dataFolder, IFormFile data, CancellationToken cancellationToken = default); Task> ListResources(string? dataFolder, string? search, CancellationToken cancellationToken = default); void ClearFolder(string? dataFolder); @@ -24,29 +22,6 @@ public class ResourcesService(IOptions resourcesConfig, ILogger : Path.Combine(resourcesConfig.Value.ResourcesFolder, dataFolder); } - public (string?, Stream?) GetInstaller(bool isStage) - { - var suiteFolder = Path.Combine(resourcesConfig.Value.ResourcesFolder, isStage - ? resourcesConfig.Value.SuiteStageInstallerFolder - : resourcesConfig.Value.SuiteInstallerFolder); - var installer = new DirectoryInfo(suiteFolder).GetFiles("AzaionSuite.Iterative*").FirstOrDefault(); - if (installer == null) - return (null, null); - - var fileStream = new FileStream(installer.FullName, FileMode.Open, FileAccess.Read); - return (installer.Name, fileStream); - } - - public async Task GetEncryptedResource(string? dataFolder, string fileName, string key, CancellationToken cancellationToken = default) - { - var fileStream = new FileStream(Path.Combine(GetResourceFolder(dataFolder), fileName), FileMode.Open, FileAccess.Read); - - var ms = new MemoryStream(); - await fileStream.EncryptTo(ms, key, cancellationToken); - ms.Seek(0, SeekOrigin.Begin); - return ms; - } - public async Task SaveResource(string? dataFolder, IFormFile data, CancellationToken cancellationToken = default) { if (data == null) diff --git a/Azaion.Services/Security.cs b/Azaion.Services/Security.cs index bd94216..eec3a9b 100644 --- a/Azaion.Services/Security.cs +++ b/Azaion.Services/Security.cs @@ -1,61 +1,10 @@ using System.Security.Cryptography; using System.Text; -using Azaion.Common.Entities; namespace Azaion.Services; public static class Security { - private const int BUFFER_SIZE = 524288; // 512 KB buffer size - public static string ToHash(this string str) => Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(str))); - - public static string GetApiEncryptionKey(string email, string password) => - $"{email}-{password}-#%@AzaionKey@%#---".ToHash(); - - public static async Task EncryptTo(this Stream inputStream, Stream toStream, string key, CancellationToken cancellationToken = default) - { - inputStream.Seek(0, SeekOrigin.Begin); - if (inputStream is { CanRead: false }) throw new ArgumentNullException(nameof(inputStream)); - if (key is not { Length: > 0 }) throw new ArgumentNullException(nameof(key)); - - using var aes = Aes.Create(); - aes.Mode = CipherMode.CBC; - aes.Padding = PaddingMode.PKCS7; - aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(key)); - aes.GenerateIV(); - using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV); - - await using var cs = new CryptoStream(toStream, encryptor, CryptoStreamMode.Write, leaveOpen: true); - - await toStream.WriteAsync(aes.IV.AsMemory(0, aes.IV.Length), cancellationToken); - - var buffer = new byte[BUFFER_SIZE]; - int bytesRead; - while ((bytesRead = await inputStream.ReadAsync(buffer, cancellationToken)) > 0) - await cs.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); - } - - public static async Task DecryptTo(this Stream encryptedStream, Stream toStream, string key, CancellationToken cancellationToken = default) - { - encryptedStream.Seek(0, SeekOrigin.Begin); - using var aes = Aes.Create(); - aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(key)); - - var iv = new byte[aes.BlockSize / 8]; - _ = await encryptedStream.ReadAsync(iv, cancellationToken); - aes.IV = iv; - aes.Mode = CipherMode.CBC; - aes.Padding = PaddingMode.PKCS7; - using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV); - - await using var cryptoStream = new CryptoStream(encryptedStream, decryptor, CryptoStreamMode.Read, leaveOpen: true); - - var buffer = new byte[BUFFER_SIZE]; - int bytesRead; - while ((bytesRead = await cryptoStream.ReadAsync(buffer, cancellationToken)) > 0) - await toStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); - toStream.Seek(0, SeekOrigin.Begin); - } } diff --git a/Azaion.Test/Azaion.Test.csproj b/Azaion.Test/Azaion.Test.csproj deleted file mode 100644 index 9e6d737..0000000 --- a/Azaion.Test/Azaion.Test.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/Azaion.Test/SecurityTest.cs b/Azaion.Test/SecurityTest.cs deleted file mode 100644 index 909e187..0000000 --- a/Azaion.Test/SecurityTest.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Azaion.Common.Extensions; -using Azaion.Services; -using FluentAssertions; -using Newtonsoft.Json; -using Xunit; - -namespace Azaion.Test; - -public class SecurityTest -{ - [Fact] - public async Task EncryptDecryptTest() - { - var testString = "Hello World Test dfvjkhsdbfvkljh sabdljsdafv asdvsad vsadfjv hbsadfkujv hgasdkvhgaksdjhvbsdv sdvsdjfhvb skdajfhb vskldfvhb lsdkfbv lsdb v" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - "sdlkfjv sdlkfvjb lsdkfjvb olsdfjvb l sdkfjvb lsdkf vblsdkfjbv lsdkfbvlksdjfbvlkdsjbfvlksdbv lksdjfbv lksdjbf vdsv sdf" + - - " sakdhvb kasjdhbv kjasdhv kjhas"; - var email = "user@azaion.com"; - var password = "testpw"; - - var key = Security.GetApiEncryptionKey(email, password); - - var encryptedStream = new MemoryStream(); - await StringToStream(testString).EncryptTo(encryptedStream, key); - - await using var decryptedStream = new MemoryStream(); - await encryptedStream.DecryptTo(decryptedStream, key); - encryptedStream.Close(); - - var str = decryptedStream.ConvertToString(); - str.Should().Be(testString); - } - - [Fact] - public async Task EncryptDecryptLargeFileTest() - { - var username = "user@azaion.com"; - var password = "testpw"; - - var key = Security.GetApiEncryptionKey(username, password); - - var largeFilePath = "large.txt"; - var largeFileDecryptedPath = "large_decrypted.txt"; - - var stream = await CreateLargeFile(largeFilePath); - stream.Seek(0, SeekOrigin.Begin); - - var encryptedStream = new MemoryStream(); - - await stream.EncryptTo(encryptedStream, key); - encryptedStream.Seek(0, SeekOrigin.Begin); - - File.Delete(largeFileDecryptedPath); - await using var decryptedStream = new FileStream(largeFileDecryptedPath, FileMode.OpenOrCreate, FileAccess.Write); - await encryptedStream.DecryptTo(decryptedStream, key); - - encryptedStream.Close(); - stream.Close(); - decryptedStream.Close(); - - await CompareFiles(largeFilePath, largeFileDecryptedPath); - File.Delete(largeFilePath); - File.Delete(largeFileDecryptedPath); - } - - private async Task CompareFiles(string largeFilePath, string largeFileDecryptedPath) - { - await using var stream1 = new FileStream(largeFilePath, FileMode.Open, FileAccess.Read); - await using var stream2 = new FileStream(largeFileDecryptedPath, FileMode.Open, FileAccess.Read); - - var sha256Bytes1 = Encoding.UTF8.GetString(await SHA256.HashDataAsync(stream1)); - var sha256Bytes2 = Encoding.UTF8.GetString(await SHA256.HashDataAsync(stream2)); - sha256Bytes1.Should().Be(sha256Bytes2); - } - - private async Task CreateLargeFile(string largeTxtPath) - { - var max = 4000000; - File.Delete(largeTxtPath); - var stream = new FileStream(largeTxtPath, FileMode.OpenOrCreate, FileAccess.ReadWrite); - var numbersList = Enumerable.Range(1, max).Chunk(100000); - foreach (var numbers in numbersList) - { - var dict = numbers.ToDictionary(x => x, _ => DateTime.UtcNow); - var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(dict, Formatting.Indented)); - await stream.WriteAsync(bytes); - Console.WriteLine($"Writing numbers from {(numbers.FirstOrDefault()*100 / (double)max):F1} %"); - } - await stream.FlushAsync(); - return stream; - } - - private static Stream StringToStream(string src) - { - var byteArray = Encoding.UTF8.GetBytes(src); - return new MemoryStream(byteArray); - } -} \ No newline at end of file diff --git a/_docs/02_document/architecture.md b/_docs/02_document/architecture.md index 92d8ed6..98a9923 100644 --- a/_docs/02_document/architecture.md +++ b/_docs/02_document/architecture.md @@ -5,11 +5,13 @@ **Problem being solved**: Azaion Suite requires a centralized admin API to manage users, assign roles, and securely distribute encrypted software resources (DLLs, AI models, installers) to authorized devices and SaaS users. **System boundaries**: -- **Inside**: User management, authentication (JWT), role-based authorization, file-based resource storage with per-user AES encryption. +- **Inside**: User management, authentication (JWT), role-based authorization, file-based resource storage (upload / list / clear). - **Outside**: Client applications (admin web panel at admin.azaion.com, fTPM-secured Jetson edge devices), PostgreSQL database, server filesystem for resource storage. > **Note (AZ-197, 2026-05-13)**: hardware-fingerprint binding (`User.Hardware`, `CheckHardwareHash`, `PUT /users/hardware/set`, `POST /resources/check`, `HardwareIdMismatch`/`BadHardware` error codes) was removed. Edge devices now ship as fTPM-secured Jetsons; server/desktop access is SaaS-only. The `User.Hardware` DB column remains as a nullable tombstone (no migration in AZ-197). +> **Note (cycle 2, 2026-05-14)**: the encrypted resource download (`POST /resources/get/{dataFolder?}`) and both installer endpoints (`GET /resources/get-installer`, `GET /resources/get-installer/stage`) were removed as obsolete. Their orphaned support code went with them: `ResourcesService.GetEncryptedResource` / `GetInstaller`, `Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo`, the `GetResourceRequest` DTO (+ `WrongResourceName` error code 50, gap kept), and the `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` properties + their env var rows in every config artifact. The `Azaion.Test` unit-test project became empty and was removed from the solution. Per-user file encryption is no longer part of the system; resource delivery is now upload + list + clear only. ADR-003 below is **retired** as a result. + **External systems**: | System | Integration Type | Direction | Purpose | @@ -76,8 +78,7 @@ **Data flow summary**: - Client → API → UserService → PostgreSQL: user CRUD operations -- Client → API → ResourcesService → Filesystem: resource upload/download -- Client → API → Security → ResourcesService: encrypted resource retrieval (key derived from user email + password; hardware-hash component removed in AZ-197) +- Client → API → ResourcesService → Filesystem: resource upload / list / clear (encrypted download + installer delivery were retired in cycle 2) ## 5. Integration Points @@ -103,10 +104,11 @@ | Requirement | Target | Measurement | Priority | |------------|--------|-------------|----------| | Max upload size | 200 MB | Kestrel MaxRequestBodySize | High | -| File encryption | AES-256-CBC | Per-resource | High | | Password hashing | SHA-384 | Per-user | Medium | | Cache TTL | 4 hours | User entity cache | Low | +> The "File encryption / AES-256-CBC" NFR was retired in cycle 2 along with the encrypted-download endpoint. See ADR-003. + No explicit availability, latency, throughput, or recovery targets found in the codebase. ## 7. Security Architecture @@ -120,7 +122,7 @@ No explicit availability, latency, throughput, or recovery targets found in the > The `apiUploaderPolicy` was added by AZ-183 and removed in the post-cycle-1 revert along with the OTA endpoints it guarded. `RoleEnum.ResourceUploader` remains as data only. **Data protection**: -- At rest: Resources encrypted with AES-256-CBC using per-user derived key (email + password). The hardware-hash component was removed in AZ-197 (sealed-Jetson + SaaS architecture). +- At rest: resource files are stored as plain bytes on the server filesystem (per-user AES-256-CBC encryption was retired in cycle 2 — see ADR-003). - In transit: HTTPS (assumed, not enforced in code) - Secrets management: Environment variables (`ASPNETCORE_*` prefix) @@ -144,15 +146,13 @@ No explicit availability, latency, throughput, or recovery targets found in the **Consequences**: Write operations are explicitly gated through `RunAdmin`. Prevents accidental writes through the reader connection. Requires maintaining two DB users with different privileges. -### ADR-003: Per-User Resource Encryption +### ADR-003: Per-User Resource Encryption — RETIRED (cycle 2, 2026-05-14) -**Context**: Resources (DLLs, AI models) must be delivered only to authorized users. +**Original context**: Resources (DLLs, AI models) had to be delivered only to authorized users via a per-download AES-256-CBC stream keyed off the user's email + password. -**Decision**: Resources are encrypted at download time using AES-256-CBC with a key derived from the user's email and password. The client must know both to decrypt. +**Retirement decision**: With the OTA delivery flow (AZ-183) and the hardware-binding flow (AZ-197) both gone, the only remaining consumer of the encrypted-download path was a now-vestigial `POST /resources/get/{dataFolder?}` endpoint and the two installer endpoints. None of them are part of the target architecture (browser SaaS + fTPM Jetsons), so the entire encrypt-on-download stack — `POST /resources/get`, `GET /resources/get-installer`, `GET /resources/get-installer/stage`, `ResourcesService.GetEncryptedResource`, `ResourcesService.GetInstaller`, `Security.GetApiEncryptionKey`, `Security.EncryptTo`, `Security.DecryptTo`, `GetResourceRequest`, `WrongResourceName` (50), `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` — was removed. `Security.ToHash` is retained because it still backs SHA-384 password hashing in `UserService`. -**Consequences**: Strong per-user binding. However, encryption happens in memory (MemoryStream), which limits practical file sizes. Key derivation is deterministic — same inputs always produce the same key. - -> **Update (AZ-197, 2026-05-13)**: the hardware-hash component of the derivation was removed. The new key formula is `SHA384(email + "-" + password + "-#%@AzaionKey@%#---")`. See ADR-004 for context on why the hardware binding was retired. +**Consequences**: resource files now live on disk as plain bytes; any future at-rest encryption must come from filesystem or storage-layer features (LUKS, object-store SSE), not from application code. ### ADR-004: Hardware Fingerprint Binding — RETIRED (AZ-197) diff --git a/_docs/02_document/common-helpers/01_helper_extensions.md b/_docs/02_document/common-helpers/01_helper_extensions.md index 78c3955..7a8fd34 100644 --- a/_docs/02_document/common-helpers/01_helper_extensions.md +++ b/_docs/02_document/common-helpers/01_helper_extensions.md @@ -5,7 +5,8 @@ Shared utility extensions used across multiple components. ## Modules - `EnumExtensions` — enum description/attribute extraction (used by BusinessException) - `StringExtensions` — PascalCase → snake_case conversion (used by AzaionDbSchemaHolder) -- `StreamExtensions` — Stream → string conversion (used by SecurityTest) + + - `QueryableExtensions` — conditional LINQ Where filter (used by UserService) ## Consumers diff --git a/_docs/02_document/common-helpers/02_helper_business_exception.md b/_docs/02_document/common-helpers/02_helper_business_exception.md index adef1db..96ae047 100644 --- a/_docs/02_document/common-helpers/02_helper_business_exception.md +++ b/_docs/02_document/common-helpers/02_helper_business_exception.md @@ -8,14 +8,16 @@ Domain exception type with catalog of business error codes (`ExceptionEnum`). | NoEmailFound | 10 | No such email found | | EmailExists | 20 | Email already exists | | WrongPassword | 30 | Passwords do not match | -| PasswordLengthIncorrect | 32 | Password should be at least 8 characters | +| PasswordLengthIncorrect | 32 | Password should be at least 12 characters (validator threshold is 12 in `RegisterUserValidator`; the description text on the enum still reads "12 characters") | | EmailLengthIncorrect | 35 | Email is empty or invalid | | WrongEmail | 37 | (no description) | -| HardwareIdMismatch | 40 | Hardware mismatch | -| BadHardware | 45 | Hardware should be not empty | -| WrongResourceName | 50 | Wrong resource file name | +| UserDisabled | 38 | User account is disabled | | NoFileProvided | 60 | No file provided | +> **Retired numeric codes — DO NOT REUSE**: +> - `40` (HardwareIdMismatch) and `45` (BadHardware) — removed by AZ-197 (cycle 1, 2026-05-13). Older clients may still surface "Hardware mismatch" UX strings keyed on these integers. +> - `50` (WrongResourceName) — removed in cycle 2 (2026-05-14) along with the `GetResourceRequest` validator (its only consumer) and the `POST /resources/get/{dataFolder?}` endpoint. + ## Consumers | Component | Usage | |-----------|-------| diff --git a/_docs/02_document/components/01_data_layer/description.md b/_docs/02_document/components/01_data_layer/description.md index 88353f1..feb0ccc 100644 --- a/_docs/02_document/components/01_data_layer/description.md +++ b/_docs/02_document/components/01_data_layer/description.md @@ -30,6 +30,8 @@ ### Entities > **Cycle 1 (2026-05-13) note** — `DetectionClass` (AZ-513) entity was added. `Resource` (AZ-183) was added then removed in the same cycle (post-cycle-1 revert; security audit F-1 + the OTA delivery model itself was deemed obsolete). The `User.Hardware` column is left in place as a tombstone (nullable, unused) per AZ-197. A UNIQUE INDEX `users_email_uidx` was added on `users.email` (security audit F-3, `env/db/06_users_email_unique.sql`). +> +> **Cycle 2 (2026-05-14) note** — `ResourcesConfig.SuiteInstallerFolder` and `SuiteStageInstallerFolder` were removed along with the installer endpoints (`GET /resources/get-installer[/stage]`); the POCO is now a single-property class (`ResourcesFolder`). ``` User: @@ -81,8 +83,7 @@ JwtConfig: ResourcesConfig: ResourcesFolder: string - SuiteInstallerFolder: string - SuiteStageInstallerFolder: string + # SuiteInstallerFolder / SuiteStageInstallerFolder removed in cycle 2 with the installer endpoints. # EncryptionMasterKey was added by AZ-183 and removed in the post-cycle-1 revert. ``` diff --git a/_docs/02_document/components/03_auth_and_security/description.md b/_docs/02_document/components/03_auth_and_security/description.md index 7eb34a4..5184b92 100644 --- a/_docs/02_document/components/03_auth_and_security/description.md +++ b/_docs/02_document/components/03_auth_and_security/description.md @@ -1,16 +1,18 @@ # Authentication & Security > **Cycle 1 (2026-05-13) note** — AZ-197 simplified `GetApiEncryptionKey` to `(email, password)` and removed `GetHWHash` outright. The hardware-binding threat model that motivated those primitives is no longer in scope (fTPM-anchored Jetsons + browser SaaS). +> +> **Cycle 2 (2026-05-14) note** — `GetApiEncryptionKey`, `EncryptTo`, and `DecryptTo` were all removed along with the encrypted-download endpoint. `Security` is now a one-method utility (`ToHash`) that backs SHA-384 password hashing. ## 1. High-Level Overview -**Purpose**: JWT token creation/validation and cryptographic utilities (password hashing, AES file encryption/decryption). +**Purpose**: JWT token creation/validation and password hashing (`Security.ToHash`). -**Architectural Pattern**: Service + static utility — `AuthService` is a DI-managed service for JWT operations; `Security` is a static class for cryptographic primitives. +**Architectural Pattern**: Service + static utility — `AuthService` is a DI-managed service for JWT operations; `Security` is a static class with a single SHA-384 helper. **Upstream dependencies**: Data Layer (JwtConfig, IUserService for GetByEmail), ASP.NET Core (IHttpContextAccessor). -**Downstream consumers**: Admin API (token creation on login, current user resolution), User Management (password hashing for both web users and provisioned devices), Resource Management (encryption key derivation, stream encryption). +**Downstream consumers**: Admin API (token creation on login, current user resolution), User Management (password hashing for both web users and provisioned devices). ## 2. Internal Interfaces @@ -26,11 +28,11 @@ | Method | Input | Output | Description | |--------|-------|--------|-------------| | `ToHash` | `string` | `string` (Base64) | SHA-384 hash | -| `GetApiEncryptionKey` | `string email, string password` | `string` (Base64) | Derives the per-user AES encryption key string. **Signature simplified by AZ-197** (`hardwareHash` parameter removed). | -| `EncryptTo` | `Stream input, Stream output, string key, CancellationToken` | void | AES-256-CBC encrypt stream | -| `DecryptTo` | `Stream encrypted, Stream output, string key, CancellationToken` | void | AES-256-CBC decrypt stream | -**Removed by AZ-197**: `GetHWHash(string hardware)` — no remaining callers in the post-cycle-1 codebase. +**Removed**: +- `GetHWHash(string hardware)` — removed by AZ-197 (cycle 1). +- `GetApiEncryptionKey(string email, string password)` — removed in cycle 2 (no remaining callers after `POST /resources/get/{dataFolder?}` was deleted). +- `EncryptTo` / `DecryptTo` extension methods — removed in cycle 2 (no remaining callers; the only consumer was `ResourcesService.GetEncryptedResource`, also deleted). ## 3. External API Specification @@ -42,7 +44,7 @@ No direct database access. `AuthService.GetCurrentUser` delegates to `IUserServi ## 5. Implementation Details -**Algorithmic Complexity**: Encryption/decryption is O(n) where n is file size, streaming in 512 KB buffers. +**Algorithmic Complexity**: SHA-384 hashing is O(n) where n is input length; in practice it operates on short password strings only. **State Management**: `AuthService` is stateless (reads claims from HTTP context per request). `Security` is purely static. @@ -54,7 +56,6 @@ No direct database access. `AuthService.GetCurrentUser` delegates to `IUserServi | Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.3 | JWT middleware integration | **Error Handling Strategy**: -- `EncryptTo` throws `ArgumentNullException` for unreadable streams or empty keys. - JWT token creation does not throw (malformed config would cause runtime errors at middleware level). - `GetCurrentUser` returns null if claims are missing or user not found. @@ -65,15 +66,12 @@ None — `Security` itself is a utility consumed by other components. ## 7. Caveats & Edge Cases **Known limitations**: -- Password hashing uses SHA-384 without per-user salt or key stretching. Not resistant to rainbow table attacks. (Unchanged by cycle 1.) -- The encryption-key salt is a hardcoded constant. (`Security.GetApiEncryptionKey` body — see `services_security.md`.) +- Password hashing uses SHA-384 without per-user salt or key stretching. Not resistant to rainbow table attacks. (Unchanged by cycles 1 and 2.) - `GetCurrentUserEmail` assumes `ClaimTypes.Name` is always present; accessing a missing key would throw `KeyNotFoundException`. -- AES encryption prepends IV as first 16 bytes — consumers must know this format. **Removed in cycle 1**: hardware fingerprint hashing was a known weakness (static salt, no rotation); deleting it via AZ-197 also removed that attack surface. -**Performance bottlenecks**: -- Large file encryption loads encrypted output into `MemoryStream` before sending — high memory usage for large files. +**Removed in cycle 2**: per-user file encryption (`GetApiEncryptionKey` + `EncryptTo` + `DecryptTo`). The hardcoded encryption-key salt and the in-memory `MemoryStream` round-trip are no longer attack / performance surfaces in this codebase. ## 8. Dependency Graph @@ -81,7 +79,7 @@ None — `Security` itself is a utility consumed by other components. **Can be implemented in parallel with**: User Management (shared dependency on Data Layer). -**Blocks**: Admin API, Resource Management (uses encryption). +**Blocks**: Admin API. (Resource Management no longer depends on this component after cycle 2 removed `EncryptTo` / `DecryptTo`.) ## 9. Logging Strategy diff --git a/_docs/02_document/components/04_resource_management/description.md b/_docs/02_document/components/04_resource_management/description.md index aff0487..164be52 100644 --- a/_docs/02_document/components/04_resource_management/description.md +++ b/_docs/02_document/components/04_resource_management/description.md @@ -1,14 +1,16 @@ # Resource Management -> **Cycle 1 (2026-05-13) note** — AZ-197 removed the `Hardware` field from `GetResourceRequest` and removed `CheckResourceRequest` and `POST /resources/check` entirely. AZ-183 introduced an OTA update path (`POST /get-update`, `POST /resources/publish`, `IResourceUpdateService`, `Resource` entity, `resources` table, `ResourcesConfig.EncryptionMasterKey`) but it was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete. The component is now back to filesystem-backed storage only. +> **Cycle 1 (2026-05-13) note** — AZ-197 removed the `Hardware` field from `GetResourceRequest` and removed `CheckResourceRequest` and `POST /resources/check` entirely. AZ-183 introduced an OTA update path (`POST /get-update`, `POST /resources/publish`, `IResourceUpdateService`, `Resource` entity, `resources` table, `ResourcesConfig.EncryptionMasterKey`) but it was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete. +> +> **Cycle 2 (2026-05-14) note** — the encrypted-download endpoint (`POST /resources/get/{dataFolder?}`) and both installer endpoints (`GET /resources/get-installer[/stage]`) were removed as obsolete. With them went `ResourcesService.GetEncryptedResource` / `GetInstaller`, `GetResourceRequest` (and `WrongResourceName = 50`), and the `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` properties + their env-var rows. The component is now upload + list + clear only and no longer depends on Authentication & Security for encryption primitives. ## 1. High-Level Overview -**Purpose**: filesystem-backed storage — upload, list, download (per-user AES-encrypted), folder clearing, installer distribution. Owned by `IResourcesService`. +**Purpose**: filesystem-backed storage — upload, list, clear. Owned by `IResourcesService`. **Architectural Pattern**: a single service over the local filesystem. No DB access, no cache. -**Upstream dependencies**: Data Layer (`ResourcesConfig`), Authentication & Security (encryption via `Security.EncryptTo`). +**Upstream dependencies**: Data Layer (`ResourcesConfig`). **Downstream consumers**: Admin API (resource endpoints). @@ -18,22 +20,18 @@ | Method | Input | Output | Async | Error Types | |--------|-------|--------|-------|-------------| -| `GetInstaller` | `bool isStage` | `(string?, Stream?)` | No | None (returns nulls if not found) | -| `GetEncryptedResource` | `string? dataFolder, string fileName, string key, CancellationToken` | `Stream` | Yes | `FileNotFoundException` | | `SaveResource` | `string? dataFolder, IFormFile data, CancellationToken` | void | Yes | `BusinessException(NoFileProvided)` | | `ListResources` | `string? dataFolder, string? search, CancellationToken` | `IEnumerable` | Yes | `DirectoryNotFoundException` | | `ClearFolder` | `string? dataFolder` | void | No | None | -**Input DTO**: -``` -GetResourceRequest (post-AZ-197): - Password: string (required, min 8 chars) - FileName: string (required, not empty) - // Hardware field removed by AZ-197. +**Removed**: +- `GetEncryptedResource` — removed in cycle 2 with the encrypted-download endpoint. +- `GetInstaller` — removed in cycle 2 with the installer endpoints. -// CheckResourceRequest — REMOVED by AZ-197. -// GetUpdateRequest, PublishResourceRequest — added by AZ-183, removed in the post-cycle-1 revert. -``` +**Removed DTOs**: +- `GetResourceRequest` — removed in cycle 2 (file deleted). +- `CheckResourceRequest` — removed by AZ-197 (cycle 1). +- `GetUpdateRequest`, `PublishResourceRequest` — removed in the post-cycle-1 AZ-183 revert. ## 3. External API Specification @@ -49,7 +47,7 @@ N/A — exposed through Admin API. ### Storage Estimates -- **Filesystem**: AI models, DLLs, installers — potentially hundreds of MB per file. +- **Filesystem**: AI models, DLLs, etc. — potentially hundreds of MB per file. ## 5. Implementation Details @@ -61,32 +59,26 @@ N/A — exposed through Admin API. - `SaveResource` throws `BusinessException(NoFileProvided)` for null uploads. - Missing files/directories throw standard .NET I/O exceptions. - `ClearFolder` silently returns if directory doesn't exist. -- `GetInstaller` returns `(null, null)` tuple if installer file is not found. ## 6. Extensions and Helpers -| Helper | Purpose | Used By | -|--------|---------|---------| -| `Security.EncryptTo` | AES stream encryption | `GetEncryptedResource` | -| `Security.GetApiEncryptionKey(email, password)` | Per-user key derivation (post-AZ-197 — no hardware component) | Admin API (before calling `GetEncryptedResource`) | +None remaining after the cycle-2 removal of `Security.EncryptTo` and `Security.GetApiEncryptionKey`. ## 7. Caveats & Edge Cases **Known limitations** (security-audit findings): - **F-2 (High)** — no path traversal protection: `dataFolder` parameter is concatenated directly with `ResourcesFolder`. A malicious `dataFolder` like `../../etc` could access arbitrary filesystem paths. Filed as separate ticket. - `SaveResource` deletes existing file before writing — no versioning or backup. -- `GetEncryptedResource` loads the entire encrypted file into a `MemoryStream` — memory-intensive for large files. - `ListResources` wraps a synchronous `DirectoryInfo.GetFiles` in `Task.FromResult` — not truly async. **Performance bottlenecks**: -- Full file encryption to memory before streaming response: memory usage scales with file size. - `ClearFolder` iterates and deletes files synchronously. ## 8. Dependency Graph -**Must be implemented after**: Data Layer (ResourcesConfig), Authentication & Security (encryption). +**Must be implemented after**: Data Layer (ResourcesConfig). -**Can be implemented in parallel with**: User Management. +**Can be implemented in parallel with**: User Management, Authentication & Security. **Blocks**: Admin API. @@ -101,6 +93,5 @@ N/A — exposed through Admin API. **Log storage**: console + rolling file (via Serilog configured in Program.cs). ## Modules Covered -- `Services/ResourcesService` -- `Common/Requests/GetResourceRequest` (post-AZ-197 — no `CheckResourceRequest`, no `Hardware` field) -- `Common/Configs/ResourcesConfig` (the `EncryptionMasterKey` field added by AZ-183 was removed in the post-cycle-1 revert) +- `Services/ResourcesService` (post-cycle-2 — only `SaveResource` / `ListResources` / `ClearFolder` remain) +- `Common/Configs/ResourcesConfig` (post-cycle-2 — only `ResourcesFolder` remains) diff --git a/_docs/02_document/components/05_admin_api/description.md b/_docs/02_document/components/05_admin_api/description.md index 3d95840..8050e23 100644 --- a/_docs/02_document/components/05_admin_api/description.md +++ b/_docs/02_document/components/05_admin_api/description.md @@ -50,12 +50,10 @@ Converts `BusinessException` to HTTP 409 JSON response: `{ ErrorCode: int, Messa | `/resources/{dataFolder?}` | POST | Authenticated | Uploads a file (up to 200 MB) | | `/resources/list/{dataFolder?}` | GET | Authenticated | Lists files | | `/resources/clear/{dataFolder?}` | POST | ApiAdmin | Clears folder | -| `/resources/get/{dataFolder?}` | POST | Authenticated | Downloads encrypted resource (key derived from `email + password` only — no Hardware) | -| `/resources/get-installer` | GET | Authenticated | Downloads production installer | -| `/resources/get-installer/stage` | GET | Authenticated | Downloads staging installer | **Removed by AZ-197**: `POST /resources/check` (was the hardware-binding side-effect probe). **Removed in post-cycle-1 revert**: `POST /get-update` and `POST /resources/publish` (AZ-183 reverted — security audit F-1; OTA delivery model itself obsolete). +**Removed in cycle 2 (2026-05-14)**: `POST /resources/get/{dataFolder?}`, `GET /resources/get-installer`, `GET /resources/get-installer/stage` — all obsolete; the encrypted-download support stack (`Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo`, `ResourcesService.GetEncryptedResource` / `GetInstaller`, `GetResourceRequest`, `WrongResourceName = 50`, `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder`) was removed with them. ADR-003 retired. ### Detection Classes | Endpoint | Method | Auth | Description | diff --git a/_docs/02_document/deployment/environment_strategy.md b/_docs/02_document/deployment/environment_strategy.md index d057f30..7f49e1e 100644 --- a/_docs/02_document/deployment/environment_strategy.md +++ b/_docs/02_document/deployment/environment_strategy.md @@ -10,7 +10,7 @@ ## Configuration ### appsettings.json Defaults -- `ResourcesConfig`: ResourcesFolder=`"Content"`, SuiteInstallerFolder=`"suite"`, SuiteStageInstallerFolder=`"suite-stage"` +- `ResourcesConfig`: ResourcesFolder=`"Content"` (the `SuiteInstallerFolder` / `SuiteStageInstallerFolder` keys were removed in cycle 2 along with the installer endpoints) - `JwtConfig`: Issuer=`"AzaionApi"`, Audience=`"Annotators/OrangePi/Admins"`, TokenLifetimeHours=`4` - `ConnectionStrings` and `JwtConfig.Secret` are NOT in appsettings — must be provided via environment variables @@ -25,8 +25,6 @@ Configuration is loaded via ASP.NET Core's `IConfiguration` with the following s | `JwtConfig.Audience` | Token audience | — | | `JwtConfig.TokenLifetimeHours` | Token TTL | — | | `ResourcesConfig.ResourcesFolder` | File storage root | — | -| `ResourcesConfig.SuiteInstallerFolder` | Prod installer dir | — | -| `ResourcesConfig.SuiteStageInstallerFolder` | Stage installer dir | — | ## Infrastructure Scripts (`env/`) diff --git a/_docs/02_document/diagrams/components.md b/_docs/02_document/diagrams/components.md index 6c780cd..5abadb6 100644 --- a/_docs/02_document/diagrams/components.md +++ b/_docs/02_document/diagrams/components.md @@ -65,11 +65,10 @@ graph TD | # | Component | Modules | Purpose | |---|-----------|---------|---------| | 01 | Data Layer | 9 | DB access, entities, configs, caching | -| 02 | User Management | 5 | User CRUD, hardware binding, role management | -| 03 | Auth & Security | 2 | JWT tokens, cryptographic utilities | -| 04 | Resource Management | 3 | File upload/download/encryption | +| 02 | User Management | 5 | User CRUD, role management, device provisioning (hardware binding removed by AZ-197) | +| 03 | Auth & Security | 2 | JWT tokens + SHA-384 password hashing (per-user file encryption removed in cycle 2) | +| 04 | Resource Management | 2 | File upload / list / clear (encrypted-download + installer endpoints removed in cycle 2) | | 05 | Admin API | 2 | HTTP endpoints, middleware, DI composition | | — | Common Helpers | 6 | Extensions, BusinessException | -| — | Tests | 2 | SecurityTest, UserServiceTest | -**Total**: 27 modules across 5 components + common helpers + tests. +**Total**: 26 modules across 5 components + common helpers. The previously listed in-process unit tests (`SecurityTest`, `UserServiceTest`) and the `Azaion.Test` project itself were removed in cycle 2; remaining test coverage lives in `e2e/Azaion.E2E/`. diff --git a/_docs/02_document/diagrams/flows/flow_encrypted_resource_download.md b/_docs/02_document/diagrams/flows/flow_encrypted_resource_download.md index c44e08b..478bd20 100644 --- a/_docs/02_document/diagrams/flows/flow_encrypted_resource_download.md +++ b/_docs/02_document/diagrams/flows/flow_encrypted_resource_download.md @@ -1,29 +1,14 @@ -# Flow: Encrypted Resource Download +# Flow: Encrypted Resource Download — OBSOLETE + +> **Removed in cycle 2 (2026-05-14).** +> +> The `POST /resources/get/{dataFolder?}` endpoint, the `ResourcesService.GetEncryptedResource` method, the `Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` helpers, the `GetResourceRequest` DTO + validator, and the `ExceptionEnum.WrongResourceName` (50) error code no longer exist. Per-user file encryption is no longer part of the system; resource files are stored as plain bytes and only ever leave the server through upload (`POST /resources/{dataFolder?}`) and admin clear (`POST /resources/clear/{dataFolder?}`). +> +> See `_docs/02_document/architecture.md` ADR-003 (retired) and `_docs/02_document/system-flows.md` flow F3 (removed) for context. +> +> This file is retained as a tombstone so historical references resolve. Do not link to it from new docs. ```mermaid -sequenceDiagram - participant Client - participant API as Admin API - participant Auth as AuthService - participant US as UserService - participant Sec as Security - participant RS as ResourcesService - participant FS as Filesystem - - Client->>API: POST /resources/get {password, hardware, fileName} - API->>Auth: GetCurrentUser() - Auth-->>API: User - API->>US: CheckHardwareHash(user, hardware) - US->>Sec: GetHWHash(hardware) - Sec-->>US: hash - US-->>API: hwHash - API->>Sec: GetApiEncryptionKey(email, password, hwHash) - Sec-->>API: AES key - API->>RS: GetEncryptedResource(folder, fileName, key) - RS->>FS: Read file - FS-->>RS: FileStream - RS->>Sec: EncryptTo(stream, key) [AES-256-CBC] - Sec-->>RS: Encrypted MemoryStream - RS-->>API: Stream - API-->>Client: 200 OK (application/octet-stream) +flowchart TD + Start([POST /resources/get — REMOVED]) --> Removed[Endpoint deleted in cycle 2] ``` diff --git a/_docs/02_document/module-layout.md b/_docs/02_document/module-layout.md index 7098481..1a36a3b 100644 --- a/_docs/02_document/module-layout.md +++ b/_docs/02_document/module-layout.md @@ -7,11 +7,11 @@ ## Layout Rules -1. This admin/ workspace is one **deployable** (the `Azaion.AdminApi` HTTP service) split across four production csproj projects + one e2e test csproj: `Azaion.AdminApi`, `Azaion.Services`, `Azaion.Common`, `Azaion.Test`, `e2e/Azaion.E2E`. +1. This admin/ workspace is one **deployable** (the `Azaion.AdminApi` HTTP service) split across three production csproj projects + one e2e test csproj: `Azaion.AdminApi`, `Azaion.Services`, `Azaion.Common`, `e2e/Azaion.E2E`. (The `Azaion.Test` unit-test project was removed in cycle 2 once its only test class — `SecurityTest.cs` — was deleted along with the encrypted-download stack; no in-process unit tests remain.) 2. Existing task specs (`_docs/02_tasks/*/AZ-*.md`) all use `Component: Admin API` as a single coarse identifier covering this entire workspace. The Per-Component Mapping below honors that convention rather than rewriting every task spec. 3. The conceptual sub-components documented in `_docs/02_document/components/01_data_layer..05_admin_api/` are **read-time** documentation aids, not write-time ownership boundaries. They are listed under "Conceptual Sub-Components" below for reference only. 4. Public API surface = the namespaces / interfaces exposed across csproj boundaries (`I*Service` interfaces in `Azaion.Services`, request DTOs in `Azaion.Common/Requests/`, entities in `Azaion.Common/Entities/`). -5. Tests live in `Azaion.Test/` (in-process unit/integration) and `e2e/Azaion.E2E/` (HTTP black-box). Production code never imports from either. +5. Tests live in `e2e/Azaion.E2E/` (HTTP black-box). Production code never imports from there. ## Per-Component Mapping @@ -23,10 +23,8 @@ - `Azaion.AdminApi/**` - `Azaion.Services/**` - `Azaion.Common/**` - - `Azaion.Test/**` - `e2e/Azaion.E2E/**` (xUnit/HttpClient-based black-box tests) - `e2e/db-init/**` (test-DB seed/init scripts consumed by the e2e harness) - - `docker.test/**` (test fixture / schema-init helpers used by `Azaion.Test`) - `docker-compose.test.yml` - **Public API** (visible to other csprojs within the workspace): - `Azaion.Services/I*Service.cs` interfaces (UserService, AuthService, ResourcesService, …) @@ -54,8 +52,8 @@ These come from `_docs/02_document/components/` and exist for reading the codeba |---|----------------------|------------------------| | 1 | Data Layer | `Azaion.Common/Database/`, `Azaion.Common/Configs/`, `Azaion.Common/Entities/` (incl. `DetectionClass.cs` added cycle 1; `Resource.cs` added then removed in same cycle — see post-cycle-1 revert) | | 2 | User Management | `Azaion.Services/UserService.cs` (incl. `RegisterDevice` added cycle 1 / AZ-196 — calls `RegisterUser` end-to-end after security-audit consolidation, finding F-3), `Azaion.Common/Requests/Register{User,DeviceResponse}.cs`, `LoginRequest.cs`, `SetUserQueueOffsetsRequest.cs` | -| 3 | Auth & Security | `Azaion.Services/AuthService.cs`, `Azaion.Services/Security.cs` (post-AZ-197 — `GetHWHash` removed; signature simplified), `Azaion.Services/Cache.cs` | -| 4 | Resource Management | `Azaion.Services/ResourcesService.cs`, `Azaion.Common/Requests/GetResourceRequest.cs` (`SetHWRequest.cs` removed by AZ-197; `ResourceUpdateService.cs` + `GetUpdateRequest.cs` + `PublishResourceRequest.cs` removed when AZ-183 was reverted) | +| 3 | Auth & Security | `Azaion.Services/AuthService.cs`, `Azaion.Services/Security.cs` (post-cycle-2 — only `ToHash` remains; `GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` removed with the encrypted-download endpoint), `Azaion.Services/Cache.cs` | +| 4 | Resource Management | `Azaion.Services/ResourcesService.cs` (`GetResourceRequest.cs` removed in cycle 2 with `POST /resources/get`; `SetHWRequest.cs` removed by AZ-197; `ResourceUpdateService.cs` + `GetUpdateRequest.cs` + `PublishResourceRequest.cs` removed when AZ-183 was reverted) | | 4b | Detection Classes | `Azaion.Services/DetectionClassService.cs` + `Azaion.Common/Requests/{Create,Update}DetectionClassRequest.cs` (added cycle 1 / AZ-513) | | 5 | Admin API (HTTP) | `Azaion.AdminApi/Program.cs`, `Azaion.AdminApi/BusinessExceptionHandler.cs`, `Azaion.AdminApi/appsettings*.json` | @@ -66,7 +64,6 @@ These come from `_docs/02_document/components/` and exist for reading the codeba | 4. Entry / Host | `Azaion.AdminApi` | `Azaion.Services`, `Azaion.Common` | | 3. Application | `Azaion.Services` | `Azaion.Common` | | 2. Foundation | `Azaion.Common` | (none) | -| —. Tests (in-process) | `Azaion.Test` | `Azaion.Services`, `Azaion.Common`, `Azaion.AdminApi` (integration only) | | —. Tests (out-of-process e2e) | `e2e/Azaion.E2E` | (none from production csprojs — HTTP only) | A reference from a lower production layer to a higher production layer is an **Architecture** finding (High severity) in `/code-review` Phase 7. Test projects may reference any production csproj; production csprojs may NOT reference test projects. @@ -75,7 +72,7 @@ A reference from a lower production layer to a higher production layer is an **A | Language | Root | Per-component path | Public API file | Test path | |----------|------|-------------------|-----------------|-----------| -| C# (.NET) | `./` (this workspace, legacy flat layout) | `.//` | namespace-root types in each csproj | `Azaion.Test/`, `e2e/Azaion.E2E/` | +| C# (.NET) | `./` (this workspace, legacy flat layout) | `.//` | namespace-root types in each csproj | `e2e/Azaion.E2E/` | ## Notes diff --git a/_docs/02_document/modules/admin_api_program.md b/_docs/02_document/modules/admin_api_program.md index edcc05e..21b3c58 100644 --- a/_docs/02_document/modules/admin_api_program.md +++ b/_docs/02_document/modules/admin_api_program.md @@ -6,6 +6,8 @@ Application entry point: configures DI, middleware, authentication, authorizatio ## Public Interface (HTTP Endpoints) > **Cycle 1 (2026-05-13) note** — endpoint surface changed by AZ-513 (detection-class CRUD), AZ-196 (device auto-registration), AZ-197 (hardware-binding removal). AZ-183 (OTA update check + publish) was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete; see `_docs/05_security/security_report.md` for context. The table reflects the post-cycle-1 state including that revert. +> +> **Cycle 2 (2026-05-14) note** — three more endpoints were removed as obsolete: `POST /resources/get/{dataFolder?}`, `GET /resources/get-installer`, `GET /resources/get-installer/stage`. The encrypted-download support stack (`Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo`, `ResourcesService.GetEncryptedResource` / `GetInstaller`, `GetResourceRequest` DTO, `WrongResourceName = 50` enum value, `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder`) went with them. ADR-003 in `architecture.md` was retired in the same change. | Method | Path | Auth | Summary | Cycle 1 origin | |--------|------|------|---------|----------------| @@ -22,23 +24,23 @@ Application entry point: configures DI, middleware, authentication, authorizatio | POST | `/resources/{dataFolder?}` | Any authenticated | Uploads a resource file | — | | GET | `/resources/list/{dataFolder?}` | Any authenticated | Lists files in a resource folder | — | | POST | `/resources/clear/{dataFolder?}` | ApiAdmin | Clears a resource folder | — | -| POST | `/resources/get/{dataFolder?}` | Any authenticated | Downloads an encrypted resource (key derived from `email + password` only) | AZ-197 wire change (no `Hardware` field) | -| GET | `/resources/get-installer` | Any authenticated | Downloads latest production installer | — | -| GET | `/resources/get-installer/stage` | Any authenticated | Downloads latest staging installer | — | | POST | `/classes` | ApiAdmin | Creates a detection class | AZ-513 | | PATCH | `/classes/{id:int}` | ApiAdmin | Updates a detection class (partial-merge) | AZ-513 | | DELETE | `/classes/{id:int}` | ApiAdmin | Deletes a detection class | AZ-513 | -### Removed in cycle 1 +### Removed endpoints -The following endpoints were removed during cycle 1 and now return `404`: +The following endpoints have been removed and now return `404`: -| Method | Path | Reason removed | -|--------|------|----------------| -| PUT | `/users/hardware/set` | AZ-197 — hardware-binding feature deleted (no fielded clients in target architecture) | -| POST | `/resources/check` | AZ-197 — was the hardware-binding side-effect probe; no remaining purpose | -| POST | `/get-update` | OTA delivery model retired post-cycle-1 (security audit F-1: endpoint disclosed plaintext per-resource encryption keys to any authenticated caller; the underlying installer-distribution flow is itself obsolete) | -| POST | `/resources/publish` | Same revert as `/get-update` — the publish counterpart of the OTA flow | +| Method | Path | Removed in | Reason | +|--------|------|------------|--------| +| PUT | `/users/hardware/set` | cycle 1 (AZ-197) | hardware-binding feature deleted (no fielded clients in target architecture) | +| POST | `/resources/check` | cycle 1 (AZ-197) | was the hardware-binding side-effect probe; no remaining purpose | +| POST | `/get-update` | post-cycle-1 (AZ-183 reverted) | security audit F-1: endpoint disclosed plaintext per-resource encryption keys to any authenticated caller; the underlying installer-distribution flow is itself obsolete | +| POST | `/resources/publish` | post-cycle-1 (AZ-183 reverted) | same revert as `/get-update` — the publish counterpart of the OTA flow | +| POST | `/resources/get/{dataFolder?}` | cycle 2 (2026-05-14) | obsolete — per-user encrypted-download flow no longer used by any client; ADR-003 retired | +| GET | `/resources/get-installer` | cycle 2 (2026-05-14) | obsolete — installer-shipping era is over (browser SaaS + fTPM Jetsons) | +| GET | `/resources/get-installer/stage` | cycle 2 (2026-05-14) | same as `/resources/get-installer` | ## Internal Logic @@ -69,7 +71,7 @@ The following endpoints were removed during cycle 1 and now return `404`: ### Configuration Sections - `JwtConfig` — JWT signing/validation - `ConnectionStrings` — DB connections -- `ResourcesConfig` — file storage paths +- `ResourcesConfig` — file storage path (`ResourcesFolder`); the installer subfolders were dropped in cycle 2 along with the installer endpoints ### Kestrel - Max request body size: 200 MB (for file uploads) diff --git a/_docs/02_document/modules/common_business_exception.md b/_docs/02_document/modules/common_business_exception.md index 45afd00..5a33b12 100644 --- a/_docs/02_document/modules/common_business_exception.md +++ b/_docs/02_document/modules/common_business_exception.md @@ -22,10 +22,11 @@ Custom exception type for domain-level errors, paired with an `ExceptionEnum` ca | `EmailLengthIncorrect` | 35 | Email is empty or invalid | | `WrongEmail` | 37 | (no description attribute) | | `UserDisabled` | 38 | User account is disabled | -| `WrongResourceName` | 50 | Wrong resource file name | | `NoFileProvided` | 60 | No file provided | -> **Cycle 1 (2026-05-13) note** — `HardwareIdMismatch = 40` and `BadHardware = 45` were removed by AZ-197 (admin-side hardware-binding cleanup). Code 40 should NOT be reused for a different meaning — older clients may still surface "Hardware mismatch" UX strings keyed on the integer. `UserDisabled = 38` was added earlier (still part of the baseline). See `_docs/03_implementation/batch_06_report.md`. +> **Cycle 1 (2026-05-13) note** — `HardwareIdMismatch = 40` and `BadHardware = 45` were removed by AZ-197 (admin-side hardware-binding cleanup). Codes 40 and 45 should NOT be reused for a different meaning — older clients may still surface "Hardware mismatch" UX strings keyed on the integer. `UserDisabled = 38` was added earlier (still part of the baseline). See `_docs/03_implementation/batch_06_report.md`. +> +> **Cycle 2 (2026-05-14) note** — `WrongResourceName = 50` was removed along with the `GetResourceRequest` validator (the only consumer). Code 50 should NOT be reused — gap kept per the cycle-1 lesson on retired numeric codes. ## Internal Logic Static constructor eagerly loads all `ExceptionEnum` descriptions into a dictionary via `EnumExtensions.GetDescriptions()`. Messages are retrieved by dictionary lookup with fallback to `ToString()`. diff --git a/_docs/02_document/modules/common_configs_resources_config.md b/_docs/02_document/modules/common_configs_resources_config.md index d938e9f..7dbd5f3 100644 --- a/_docs/02_document/modules/common_configs_resources_config.md +++ b/_docs/02_document/modules/common_configs_resources_config.md @@ -1,15 +1,15 @@ # Module: Azaion.Common.Configs.ResourcesConfig ## Purpose -Configuration POCO for file resource storage paths, bound from `appsettings.json` section `ResourcesConfig`. +Configuration POCO for the file resource storage root, bound from `appsettings.json` section `ResourcesConfig`. + +> **Cycle 2 (2026-05-14) note** — `SuiteInstallerFolder` and `SuiteStageInstallerFolder` were removed along with the installer endpoints (`GET /resources/get-installer[/stage]`) and `ResourcesService.GetInstaller`. Their `ASPNETCORE_ResourcesConfig__SuiteInstallerFolder` / `__SuiteStageInstallerFolder` env-var rows were removed from `appsettings.json`, `.env.example`, `secrets/staging.public.env`, `secrets/production.public.env`, and `docker-compose.test.yml`. ## Public Interface | Property | Type | Description | |----------|------|-------------| | `ResourcesFolder` | `string` | Root directory for uploaded resource files | -| `SuiteInstallerFolder` | `string` | Subdirectory for production installer files | -| `SuiteStageInstallerFolder` | `string` | Subdirectory for staging installer files | ## Internal Logic None — pure data class. @@ -18,7 +18,7 @@ None — pure data class. None. ## Consumers -- `ResourcesService` — uses all three properties to resolve file paths +- `ResourcesService` — uses `ResourcesFolder` to resolve upload / list / clear paths ## Data Models None. @@ -30,7 +30,7 @@ Bound via `builder.Configuration.GetSection(nameof(ResourcesConfig))` in `Progra None. ## Security -Paths control where files are read from and written to on the server's filesystem. +Path controls where files are read from and written to on the server's filesystem. ## Tests None. diff --git a/_docs/02_document/modules/common_entities_user.md b/_docs/02_document/modules/common_entities_user.md index 74a82dc..76e8cac 100644 --- a/_docs/02_document/modules/common_entities_user.md +++ b/_docs/02_document/modules/common_entities_user.md @@ -59,4 +59,4 @@ None. `PasswordHash` stores SHA-384 hash. `Hardware` stores raw hardware fingerprint (hashed for comparison via `Security.GetHWHash`). ## Tests -Indirectly tested via `UserServiceTest` and `SecurityTest`. +Indirectly tested end-to-end via `e2e/Azaion.E2E/Tests/LoginTests.cs`, `UserManagementTests.cs`, and `DeviceTests.cs`. (The previous in-process `Azaion.Test/UserServiceTest` and `SecurityTest` were both removed by cycle 2 along with the `Azaion.Test` project.) diff --git a/_docs/02_document/modules/common_extensions_stream_extensions.md b/_docs/02_document/modules/common_extensions_stream_extensions.md deleted file mode 100644 index ac33e62..0000000 --- a/_docs/02_document/modules/common_extensions_stream_extensions.md +++ /dev/null @@ -1,34 +0,0 @@ -# Module: Azaion.Common.Extensions.StreamExtensions - -## Purpose -Stream-to-string conversion utility. - -## Public Interface - -| Method | Signature | Description | -|--------|-----------|-------------| -| `ConvertToString` | `static string ConvertToString(this Stream stream)` | Reads entire stream as UTF-8 string, resets position to 0 afterward | - -## Internal Logic -Resets stream position to 0, reads via `StreamReader`, then resets again so the stream remains usable. - -## Dependencies -- `System.Text.Encoding`, `System.IO.StreamReader` (BCL only) - -## Consumers -- `SecurityTest.EncryptDecryptTest` — converts decrypted stream to string for assertion - -## Data Models -None. - -## Configuration -None. - -## External Integrations -None. - -## Security -None. - -## Tests -Indirectly tested via `SecurityTest.EncryptDecryptTest`. diff --git a/_docs/02_document/modules/common_requests_get_resource.md b/_docs/02_document/modules/common_requests_get_resource.md deleted file mode 100644 index 765be3d..0000000 --- a/_docs/02_document/modules/common_requests_get_resource.md +++ /dev/null @@ -1,46 +0,0 @@ -# Module: Azaion.Common.Requests.GetResourceRequest - -## Purpose -Request DTO and validator for the `POST /resources/get/{dataFolder?}` endpoint. The user's password is supplied per-request so the server can derive the per-user AES encryption key for the response stream. - -> **Cycle 1 (2026-05-13) note** — the `Hardware` property and its `BadHardware` validator rule were removed by AZ-197 (admin-side hardware-binding cleanup). The wire-compat policy was "drop entirely" — any client still sending `Hardware` will not see it deserialized. The companion `CheckResourceRequest` was removed along with the `POST /resources/check` endpoint. See `_docs/03_implementation/batch_06_report.md`. - -## Public Interface - -### GetResourceRequest -| Property | Type | Description | -|----------|------|-------------| -| `Password` | `string` | User's password (used to derive the encryption key) | -| `FileName` | `string` | Resource file to retrieve | - -### GetResourceRequestValidator -| Rule | Constraint | Error Code | -|------|-----------|------------| -| `Password` min length | >= 8 chars | `PasswordLengthIncorrect` | -| `FileName` not empty | Required | `WrongResourceName` | - -## Internal Logic -Validator uses `BusinessException.GetMessage()` to derive user-facing error messages from `ExceptionEnum`. - -## Dependencies -- `BusinessException`, `ExceptionEnum` -- FluentValidation - -## Consumers -- `Program.cs` `POST /resources/get/{dataFolder?}` endpoint - -## Data Models -None. - -## Configuration -None. - -## External Integrations -None. - -## Security -- Password is sent in the POST body (not URL) to avoid logging in access logs. -- Per-user encryption key derivation now uses `email + password` only (see `services_security.md`). - -## Tests -- `e2e/Azaion.E2E/Tests/ResourceTests.cs` (encrypted download / round-trip) — updated by AZ-197 to stop sending `Hardware` diff --git a/_docs/02_document/modules/services_auth_service.md b/_docs/02_document/modules/services_auth_service.md index dff8faa..dd7bd79 100644 --- a/_docs/02_document/modules/services_auth_service.md +++ b/_docs/02_document/modules/services_auth_service.md @@ -27,7 +27,7 @@ Private method: ## Consumers - `Program.cs` `/login` endpoint — calls `CreateToken` after successful validation -- `Program.cs` `/users/current`, `/resources/get`, `/resources/get-installer`, `/resources/check` — call `GetCurrentUser` +- `Program.cs` `/users/current` — calls `GetCurrentUser` (the previously listed `/resources/get`, `/resources/get-installer`, `/resources/check` consumers were removed in cycle 2 / by AZ-197 along with their endpoints) ## Data Models None. diff --git a/_docs/02_document/modules/services_resources_service.md b/_docs/02_document/modules/services_resources_service.md index 0d98fc9..5bb8ae6 100644 --- a/_docs/02_document/modules/services_resources_service.md +++ b/_docs/02_document/modules/services_resources_service.md @@ -1,23 +1,21 @@ # Module: Azaion.Services.ResourcesService ## Purpose -File-based resource management: upload, list, download (encrypted), clear, and installer retrieval from the server's filesystem. +File-based resource management: upload, list, and clear files in the server's filesystem. + +> **Cycle 2 (2026-05-14) note** — `GetInstaller` and `GetEncryptedResource` were removed along with the `POST /resources/get/{dataFolder?}` and `GET /resources/get-installer[/stage]` endpoints; the corresponding interface methods, the `Security.EncryptTo` dependency, and the `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` properties went with them. The service is now upload + list + clear only. ## Public Interface ### IResourcesService | Method | Signature | Description | |--------|-----------|-------------| -| `GetInstaller` | `(string?, Stream?) GetInstaller(bool isStage)` | Returns the latest installer file (prod or stage) | -| `GetEncryptedResource` | `Task GetEncryptedResource(string? dataFolder, string fileName, string key, CancellationToken ct)` | Reads a file and returns it AES-encrypted | | `SaveResource` | `Task SaveResource(string? dataFolder, IFormFile data, CancellationToken ct)` | Saves an uploaded file to the resource folder | | `ListResources` | `Task> ListResources(string? dataFolder, string? search, CancellationToken ct)` | Lists file names in a resource folder, optionally filtered | | `ClearFolder` | `void ClearFolder(string? dataFolder)` | Deletes all files and subdirectories in the specified folder | ## Internal Logic - **GetResourceFolder**: resolves the target directory. If `dataFolder` is null/empty, uses `ResourcesConfig.ResourcesFolder` directly; otherwise, appends it as a subdirectory. -- **GetInstaller**: scans the installer folder for files matching `"AzaionSuite.Iterative*"`, returns the first match as a `FileStream`. -- **GetEncryptedResource**: opens the file, encrypts via `Security.EncryptTo` extension into a `MemoryStream`, returns the encrypted stream. - **SaveResource**: creates the folder if needed, deletes any existing file with the same name, then copies the uploaded file. - **ListResources**: uses `DirectoryInfo.GetFiles` with optional search pattern. - **ClearFolder**: iterates and deletes all files and subdirectories. @@ -26,24 +24,22 @@ File-based resource management: upload, list, download (encrypted), clear, and i - `IOptions` — folder paths - `ILogger` — logs successful saves - `BusinessException` — thrown for null file uploads -- `Security.EncryptTo` — stream encryption extension ## Consumers -- `Program.cs` — all `/resources/*` endpoints +- `Program.cs` — `POST /resources/{dataFolder?}` (upload), `GET /resources/list/{dataFolder?}`, `POST /resources/clear/{dataFolder?}` ## Data Models None. ## Configuration -Uses `ResourcesConfig` (ResourcesFolder, SuiteInstallerFolder, SuiteStageInstallerFolder). +Uses `ResourcesConfig.ResourcesFolder`. ## External Integrations Local filesystem for resource storage. ## Security -- Resources are encrypted per-user using a key derived from `email + password` (the hardware-hash component was removed by AZ-197 — see `services_security.md`). - File deletion overwrites existing files before writing new ones. -- No path traversal protection on `dataFolder` parameter. +- No path traversal protection on `dataFolder` parameter (security audit F-2 — open). ## Tests -None at the module level. End-to-end coverage lives in `e2e/Azaion.E2E/Tests/ResourceTests.cs` (encrypted download / round-trip / 200 MB upload limit) — updated by AZ-197 to stop sending the `Hardware` field. +End-to-end coverage in `e2e/Azaion.E2E/Tests/ResourceTests.cs` — `File_upload_succeeds` and `Upload_without_file_is_rejected_with_400_or_409_and_60_on_conflict`. diff --git a/_docs/02_document/modules/services_security.md b/_docs/02_document/modules/services_security.md index 01c1ab8..1b190b3 100644 --- a/_docs/02_document/modules/services_security.md +++ b/_docs/02_document/modules/services_security.md @@ -1,50 +1,39 @@ # Module: Azaion.Services.Security ## Purpose -Static utility class providing cryptographic operations: password hashing, encryption key derivation, and AES-CBC stream encryption/decryption. +Static utility class providing the SHA-384 password hashing helper used by `UserService`. -> **Cycle 1 (2026-05-13) note** — `GetHWHash` was deleted and `GetApiEncryptionKey` was simplified from `(email, password, hardwareHash)` to `(email, password)` by AZ-197 (admin-side hardware-binding cleanup). The hardware-hash component of the derived key is gone; existing ciphertexts produced under the old derivation are no longer re-derivable from the new signature. See `_docs/03_implementation/batch_06_report.md`. +> **Cycle 1 (2026-05-13) note** — `GetHWHash` was deleted and `GetApiEncryptionKey` was simplified from `(email, password, hardwareHash)` to `(email, password)` by AZ-197. +> +> **Cycle 2 (2026-05-14) note** — `GetApiEncryptionKey`, `EncryptTo`, and `DecryptTo` were all removed along with the encrypted-download endpoint. Only `ToHash` remains; it still backs SHA-384 password hashing in `UserService` (`PasswordHash = request.Password.ToHash()`). The `Azaion.Test/SecurityTest.cs` unit tests went with the removed methods, leaving the `Azaion.Test` project empty (also removed from the solution). See `_docs/06_metrics/retro_2026-05-14.md` once cycle 2's retro lands. ## Public Interface | Method | Signature | Description | |--------|-----------|-------------| | `ToHash` | `static string ToHash(this string str)` | Extension: SHA-384 hash of input, returned as Base64 | -| `GetApiEncryptionKey` | `static string GetApiEncryptionKey(string email, string password)` | Derives the per-user AES encryption key string from email + password (+ static salt) | -| `EncryptTo` | `static async Task EncryptTo(this Stream inputStream, Stream toStream, string key, CancellationToken ct)` | AES-256-CBC encrypts a stream; prepends IV to output | -| `DecryptTo` | `static async Task DecryptTo(this Stream encryptedStream, Stream toStream, string key, CancellationToken ct)` | Reads IV prefix, then AES-256-CBC decrypts stream | ## Internal Logic -- **Password hashing**: `ToHash` uses SHA-384 with UTF-8 encoding, outputting Base64. -- **Encryption key derivation**: `GetApiEncryptionKey` concatenates email and password with the static salt `"-#%@AzaionKey@%#---"`, then hashes via `ToHash` (SHA-384, Base64). -- **Encryption**: AES-256-CBC with PKCS7 padding. Key is SHA-256 of the derived key string. IV is randomly generated and prepended to the output stream. Uses 512 KB buffer for streaming. -- **Decryption**: Reads the first 16 bytes as IV, then AES-256-CBC decrypts with PKCS7 padding. +- `ToHash` uses SHA-384 with UTF-8 encoding, outputting Base64. ## Dependencies -- `System.Security.Cryptography` (Aes, SHA256, SHA384) +- `System.Security.Cryptography` (SHA384) - `System.Text.Encoding` ## Consumers -- `Program.cs` `/resources/get/{dataFolder}` endpoint — calls `GetApiEncryptionKey(user.Email, request.Password)` -- `ResourcesService.GetEncryptedResource` — uses `EncryptTo` extension -- `Azaion.Test/SecurityTest` — directly tests `EncryptTo` / `DecryptTo` round-trips (no longer tests hardware-hash derivation) +- `Azaion.Services/UserService.cs` — `RegisterUser` (password storage) and `ValidateUser` (login comparison) both call `request.Password.ToHash()` ## Data Models None. ## Configuration -- `BUFFER_SIZE = 524288` (512 KB) — hardcoded streaming buffer size +None. ## External Integrations None. ## Security -Core cryptographic module. Key observations: -- Passwords are hashed with SHA-384 (no per-user salt, no key stretching — not bcrypt/scrypt/argon2). This is unchanged by AZ-197. -- AES encryption uses SHA-256 of the derived key, with random IV per encryption. -- All salts/prefixes are hardcoded constants. -- Per AZ-197: device hardware fingerprints no longer participate in key derivation. The threat that hardware binding mitigated (credential reuse via desktop installers) was eliminated by the architectural shift to fTPM-secured Jetsons + browser-only SaaS access. +- Password hashing uses SHA-384 with no per-user salt and no key stretching. Not resistant to rainbow-table attacks (security audit F-7 — open). Unchanged by cycles 1 and 2. ## Tests -- `Azaion.Test/SecurityTest.EncryptDecryptTest` — round-trip encrypt/decrypt of a string -- `Azaion.Test/SecurityTest.EncryptDecryptLargeFileTest` — round-trip encrypt/decrypt of a ~400 MB generated file +None at the unit-test level after the `Azaion.Test` project was removed in cycle 2. `ToHash` is exercised end-to-end through every login / register e2e test (`e2e/Azaion.E2E/Tests/`). diff --git a/_docs/02_document/modules/services_user_service.md b/_docs/02_document/modules/services_user_service.md index aa8b3d5..11fc2c5 100644 --- a/_docs/02_document/modules/services_user_service.md +++ b/_docs/02_document/modules/services_user_service.md @@ -63,5 +63,7 @@ PostgreSQL via `IDbFactory`. - Read operations use the read-only DB connection; writes use the admin connection. ## Tests -- `Azaion.Test/UserServiceTest.cs` — unit/integration tests against the live test database (hardware-binding tests removed by AZ-197) - `e2e/Azaion.E2E/Tests/DeviceTests.cs` — e2e for AZ-196 device-provisioning ACs +- `e2e/Azaion.E2E/Tests/UserManagementTests.cs` and `LoginTests.cs` — e2e coverage for the rest of the user lifecycle (login, register, role change, enable/disable, delete, queue offsets) + +(Unit-test coverage in `Azaion.Test/UserServiceTest.cs` was removed earlier with the AZ-197 hardware-binding cleanup; the `Azaion.Test` project itself was removed from the solution in cycle 2 once its only remaining file — `SecurityTest.cs` — was deleted with the encrypted-download stack.) diff --git a/_docs/02_document/modules/test_security_test.md b/_docs/02_document/modules/test_security_test.md deleted file mode 100644 index c3a2e7a..0000000 --- a/_docs/02_document/modules/test_security_test.md +++ /dev/null @@ -1,45 +0,0 @@ -# Module: Azaion.Test.SecurityTest - -## Purpose -xUnit tests for the `Security` encryption/decryption functionality. - -## Public Interface - -| Test | Description | -|------|-------------| -| `EncryptDecryptTest` | Round-trip encrypt/decrypt of a ~1 KB string; asserts decrypted output matches original | -| `EncryptDecryptLargeFileTest` | Round-trip encrypt/decrypt of a ~400 MB generated file; compares SHA-256 hashes of original and decrypted files | - -## Internal Logic -- **EncryptDecryptTest**: creates a key via `Security.GetApiEncryptionKey`, encrypts a test string to a `MemoryStream`, decrypts back, compares with `FluentAssertions`. -- **EncryptDecryptLargeFileTest**: generates a large JSON file (4M numbers chunked), encrypts, decrypts to a new file, compares file hashes via `SHA256.HashDataAsync`. - -Private helpers: -- `CompareFiles` — SHA-256 hash comparison of two files -- `CreateLargeFile` — generates a large file by serializing number dictionaries in 100K chunks -- `StringToStream` — converts a UTF-8 string to a `MemoryStream` - -## Dependencies -- `Security` (encrypt/decrypt) -- `StreamExtensions.ConvertToString` -- `FluentAssertions` -- `Newtonsoft.Json` -- xUnit - -## Consumers -None — test module. - -## Data Models -None. - -## Configuration -None. - -## External Integrations -Local filesystem (creates/deletes `large.txt` and `large_decrypted.txt` during large file test). - -## Security -None. - -## Tests -This IS the test module. diff --git a/_docs/02_document/modules/test_user_service_test.md b/_docs/02_document/modules/test_user_service_test.md deleted file mode 100644 index 3050217..0000000 --- a/_docs/02_document/modules/test_user_service_test.md +++ /dev/null @@ -1,39 +0,0 @@ -# Module: Azaion.Test.UserServiceTest - -## Purpose -xUnit integration test for `UserService.CheckHardwareHash` against a live PostgreSQL database. - -## Public Interface - -| Test | Description | -|------|-------------| -| `CheckHardwareHashTest` | Looks up a known user by email, then calls `CheckHardwareHash` with a hardware fingerprint string | - -## Internal Logic -- Creates a `DbFactory` with hardcoded connection strings pointing to a remote PostgreSQL instance. -- Creates a `UserService` with that factory and a fresh `MemoryCache`. -- Fetches user `spielberg@azaion.com`, then calls `CheckHardwareHash` with a specific hardware string. -- No assertion — the test only verifies no exception is thrown. - -## Dependencies -- `UserService`, `DbFactory`, `MemoryCache` -- `ConnectionStrings`, `OptionsWrapper` -- xUnit - -## Consumers -None — test module. - -## Data Models -None. - -## Configuration -Hardcoded connection strings to `188.245.120.247:4312` (remote database). - -## External Integrations -Live PostgreSQL database (remote server). - -## Security -Contains hardcoded database credentials in source code. This is a security concern — credentials should be in test configuration or environment variables. - -## Tests -This IS the test module. diff --git a/_docs/02_document/system-flows.md b/_docs/02_document/system-flows.md index f39b016..d807e30 100644 --- a/_docs/02_document/system-flows.md +++ b/_docs/02_document/system-flows.md @@ -1,6 +1,8 @@ # Azaion Admin API — System Flows > **Cycle 1 (2026-05-13) note** — F4 (Hardware Check) was deleted by AZ-197; F3 no longer depends on hardware. Two new flows were added: F8 Detection Classes CRUD (AZ-513), F9 Device Auto-Provisioning (AZ-196). F10 OTA Update Check & Publish (AZ-183) was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete; see `_docs/05_security/security_report.md` for context. F3's narrative was updated to drop the hardware-check step. +> +> **Cycle 2 (2026-05-14) note** — F3 (Encrypted Resource Download) and F6 (Installer Download) were removed entirely as obsolete. The encrypted-download support stack (`Security.GetApiEncryptionKey`, `EncryptTo`, `DecryptTo`, `ResourcesService.GetEncryptedResource`, `ResourcesService.GetInstaller`, `GetResourceRequest`, `WrongResourceName` (50)) and the installer config (`SuiteInstallerFolder`, `SuiteStageInstallerFolder`) all went with them. See `_docs/02_document/architecture.md` ADR-003 (retired). ## Flow Inventory @@ -8,10 +10,10 @@ |---|-----------|---------|-------------------|-------------| | F1 | User Login | POST /login | Admin API, User Mgmt, Auth & Security | High | | F2 | User Registration | POST /users | Admin API, User Mgmt | High | -| F3 | Encrypted Resource Download | POST /resources/get | Admin API, Auth, User Mgmt, Resource Mgmt | High | +| ~~F3~~ | ~~Encrypted Resource Download~~ | ~~POST /resources/get~~ | — | **REMOVED — cycle 2 (obsolete)** | | ~~F4~~ | ~~Hardware Check~~ | ~~POST /resources/check~~ | — | **REMOVED — AZ-197** | | F5 | Resource Upload | POST /resources | Admin API, Resource Mgmt | Medium | -| F6 | Installer Download | GET /resources/get-installer | Admin API, Auth, Resource Mgmt | Medium | +| ~~F6~~ | ~~Installer Download~~ | ~~GET /resources/get-installer~~ | — | **REMOVED — cycle 2 (obsolete)** | | F7 | User Management (CRUD) | Various /users/* | Admin API, User Mgmt | Medium | | F8 | Detection Classes CRUD *(AZ-513)* | POST/PATCH/DELETE /classes | Admin API, DetectionClassService | High | | F9 | Device Auto-Provisioning *(AZ-196)* | POST /devices | Admin API, User Mgmt | High | @@ -23,10 +25,8 @@ |------|-----------|-----------------| | F1 | — | All other flows (produces JWT token) | | F2 | — | F1, F9 (creates user records — including device users via F9) | -| F3 | F1 (requires JWT) | — (post-AZ-197: no hardware-binding dependency) | -| F5 | F1 (requires JWT) | F3 (uploaded resources are later downloaded) | -| F6 | F1 (requires JWT) | — | -| F7 | F1 (requires JWT, ApiAdmin role) | F3 (user data) | +| F5 | F1 (requires JWT) | — | +| F7 | F1 (requires JWT, ApiAdmin role) | — | | F8 | F1 (requires JWT, ApiAdmin role) | UI Detection Classes table | | F9 | F1 (requires JWT, ApiAdmin role) | F2 (writes a user row, but reuses `RegisterUser` end-to-end), F1 (provisioned devices later log in) | @@ -112,48 +112,9 @@ sequenceDiagram --- -## Flow F3: Encrypted Resource Download +## Flow F3: Encrypted Resource Download — REMOVED (cycle 2, 2026-05-14) -> **Updated by AZ-197 (2026-05-13)** — the hardware-binding precondition and the `CheckHardwareHash` / `GetHWHash` steps were removed; the encryption key is now derived from `email + password` only. The diagram below reflects the post-cycle-1 path. - -### Description -An authenticated user requests a resource file. The system derives a per-user encryption key from email + password, encrypts the file with AES-256-CBC, and streams the encrypted content. - -### Preconditions -- User is authenticated (JWT) -- Resource file exists on server - -### Sequence Diagram - -```mermaid -sequenceDiagram - participant Client - participant API as Admin API - participant Auth as AuthService - participant Sec as Security - participant RS as ResourcesService - participant FS as Filesystem - - Client->>API: POST /resources/get {password, fileName} - API->>Auth: GetCurrentUser() - Auth-->>API: User - API->>Sec: GetApiEncryptionKey(email, password) - Sec-->>API: AES key string - API->>RS: GetEncryptedResource(folder, fileName, key) - RS->>FS: Read file - FS-->>RS: FileStream - RS->>Sec: EncryptTo(stream, key) - Sec-->>RS: Encrypted MemoryStream - RS-->>API: Stream - API-->>Client: 200 OK (application/octet-stream) -``` - -### Error Scenarios - -| Error | Where | Detection | Recovery | -|-------|-------|-----------|----------| -| Not authenticated | API | No/invalid JWT | 401 Unauthorized | -| File not found | ResourcesService | FileStream throws | 500 Internal Server Error | +The `POST /resources/get/{dataFolder?}` endpoint and its supporting stack (`Security.GetApiEncryptionKey`, `Security.EncryptTo`, `Security.DecryptTo`, `ResourcesService.GetEncryptedResource`, `GetResourceRequest` DTO + validator, `ExceptionEnum.WrongResourceName` (50)) were removed as obsolete. Per-user file encryption is no longer part of the system; resource files are now stored as plain bytes and only ever leave the server through the upload (F5) and admin clear paths. ADR-003 in `architecture.md` was retired in the same change. --- @@ -195,34 +156,9 @@ sequenceDiagram --- -## Flow F6: Installer Download +## Flow F6: Installer Download — REMOVED (cycle 2, 2026-05-14) -### Description -An authenticated user downloads the latest Azaion Suite installer (production or staging). - -### Preconditions -- User is authenticated (JWT) -- Installer file exists on server - -### Sequence Diagram - -```mermaid -sequenceDiagram - participant Client - participant API as Admin API - participant Auth as AuthService - participant RS as ResourcesService - participant FS as Filesystem - - Client->>API: GET /resources/get-installer - API->>Auth: GetCurrentUser() - Auth-->>API: User (not null) - API->>RS: GetInstaller(isStage: false) - RS->>FS: Scan for AzaionSuite.Iterative* - FS-->>RS: FileInfo - RS-->>API: (name, FileStream) - API-->>Client: 200 OK (application/octet-stream) -``` +The `GET /resources/get-installer` and `GET /resources/get-installer/stage` endpoints, the `ResourcesService.GetInstaller` method, the `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` configuration properties, and their environment-variable rows in every config artifact (`appsettings.json`, `.env.example`, `secrets/*.public.env`, `docker-compose.test.yml`) were removed. The installer-shipping era is over in the target architecture (browser SaaS + fTPM Jetsons); installer artefacts are no longer served from the Admin API. --- diff --git a/_docs/02_document/tests/blackbox-tests.md b/_docs/02_document/tests/blackbox-tests.md index 1249538..95e8769 100644 --- a/_docs/02_document/tests/blackbox-tests.md +++ b/_docs/02_document/tests/blackbox-tests.md @@ -184,51 +184,17 @@ --- -### FT-P-09: Download Encrypted Resource +### FT-P-09: Download Encrypted Resource — OBSOLETE (cycle 2, 2026-05-14) -**Summary**: Authenticated user downloads an encrypted resource file. -**Traces to**: AC-14, AC-18 -**Category**: Resource Distribution +The `POST /resources/get/{dataFolder?}` endpoint, the `Security.GetApiEncryptionKey` / `EncryptTo` helpers, the `ResourcesService.GetEncryptedResource` method, the `GetResourceRequest` DTO, and the e2e tests `Encrypted_download_returns_octet_stream_and_non_empty_body` (in `ResourceTests.cs`) and `Per_user_encryption_produces_distinct_ciphertext_for_same_file` (in `SecurityTests.cs`) were all removed. The endpoint now returns 404 — verified by FT-N-16 below. -**Preconditions**: -- User authenticated, hardware bound, resource file uploaded - -**Input data**: `{"password":"validpwd1","hardware":"test-hw-001","fileName":"test.txt"}` - -**Steps**: - -| Step | Consumer Action | Expected System Response | -|------|----------------|------------------------| -| 1 | POST /resources/get with credentials | HTTP 200, Content-Type: application/octet-stream, non-empty body | - -**Expected outcome**: HTTP 200 with encrypted binary content -**Max execution time**: 10s +ID retained for traceability stability; do not regenerate the spec body until a full `/test-spec` rerun. --- -### FT-P-10: Encryption Round-Trip Verification +### FT-P-10: Encryption Round-Trip Verification — OBSOLETE (cycle 2, 2026-05-14) -**Summary**: Downloaded encrypted resource decrypts to original file content. -**Traces to**: AC-15, AC-19 -**Category**: Resource Distribution - -**Preconditions**: -- Known file uploaded, user credentials known - -**Input data**: Original file content, user email, password, hardware hash - -**Steps**: - -| Step | Consumer Action | Expected System Response | -|------|----------------|------------------------| -| 1 | Upload known file | HTTP 200 | -| 2 | Download encrypted file via API | HTTP 200, encrypted bytes | -| 3 | Derive AES key from email + password + hwHash | Key bytes | -| 4 | Decrypt downloaded content with derived key | Decrypted bytes | -| 5 | Compare decrypted bytes with original | Byte-level equality | - -**Expected outcome**: Decrypted content matches original file exactly -**Max execution time**: 10s +Same removal as FT-P-09. Additionally `Security.DecryptTo` and the e2e test `Encryption_round_trip_decrypt_matches_original_bytes` (in `ResourceTests.cs`) are gone. ID retained for traceability stability. --- @@ -487,12 +453,38 @@ The following legacy entries describe behaviour removed by AZ-197 (admin-side ha - FT-P-04 (First Hardware Check Stores Fingerprint) — superseded; the `POST /resources/check` endpoint and the hardware-store side-effect were removed. - FT-P-05 (Subsequent Hardware Check Matches) — superseded; same endpoint removed. - FT-N-06 (Hardware Mismatch) — superseded; the `HardwareIdMismatch` / error code 40 path no longer exists in `ExceptionEnum`. -- FT-P-09 / FT-P-10 wire shape — the `hardware` field on `POST /resources/get/{dataFolder}` is no longer required; the encryption key is now derived from `email + password` only. The tests still pass without the field; do not regenerate spec bodies until a full `/test-spec` rerun. +- FT-P-09 / FT-P-10 — fully obsolete after the cycle-2 cleanup; the endpoint, support code, and corresponding e2e tests are gone (see the FT-P-09 / FT-P-10 stubs above and FT-N-16 below). See `_docs/03_implementation/batch_06_report.md` for the full AZ-197 implementation rationale and the wire-compat policy decision (drop entirely). --- +### Cycle-2 Cleanup (2026-05-14) — Obsolete Resource Endpoints Removed + +#### FT-N-16: Removed Resource Endpoints Return 404 + +**Summary**: After the cycle-2 cleanup, the three obsolete resource endpoints are no longer routed and return 404. +**Traces to**: Cycle-2 AC-1, Cycle-2 AC-2, Cycle-2 AC-3 +**Category**: Negative — Removed Endpoints + +**Preconditions**: +- Caller authenticated as any user (404 must precede any auth check, since the route is gone) + +**Steps**: + +| Step | Consumer Action | Expected System Response | +|------|----------------|------------------------| +| 1 | `POST /resources/get` (with or without body) | HTTP 404 | +| 2 | `POST /resources/get/somefolder` | HTTP 404 | +| 3 | `GET /resources/get-installer` | HTTP 404 | +| 4 | `GET /resources/get-installer/stage` | HTTP 404 | + +**Expected outcome**: each request returns HTTP 404 (not 401, not 405); no `Security.GetApiEncryptionKey` / `EncryptTo` invocation observable in logs. + +**Notes**: this is a parallel to FT-N-15 (which covers the AZ-197 endpoint removals). Together they enumerate every route that has been retired in cycles 1 and 2. + +--- + ### Detection Classes CRUD (AZ-513) #### FT-P-14: POST /classes Creates Detection Class diff --git a/_docs/02_document/tests/security-tests.md b/_docs/02_document/tests/security-tests.md index e547334..0b1f2dd 100644 --- a/_docs/02_document/tests/security-tests.md +++ b/_docs/02_document/tests/security-tests.md @@ -10,13 +10,15 @@ | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | GET /users (no JWT) | HTTP 401 | -| 2 | POST /resources/get (no JWT) | HTTP 401 | -| 3 | POST /resources/check (no JWT) | HTTP 401 | -| 4 | GET /resources/get-installer (no JWT) | HTTP 401 | -| 5 | PUT /users/role (no JWT) | HTTP 401 | -| 6 | DELETE /users (no JWT) | HTTP 401 | +| 2 | POST /resources/{folder} upload (no JWT) | HTTP 401 | +| 3 | GET /resources/list/{folder} (no JWT) | HTTP 401 | +| 4 | PUT /users/{email}/set-role/{role} (no JWT) | HTTP 401 | +| 5 | DELETE /users/{email} (no JWT) | HTTP 401 | +| 6 | POST /classes (no JWT) | HTTP 401 | -**Pass criteria**: All endpoints return HTTP 401 for unauthenticated requests +**Pass criteria**: All remaining protected endpoints return HTTP 401 for unauthenticated requests. + +> Earlier revisions of this scenario also covered `POST /resources/get`, `POST /resources/check`, and `GET /resources/get-installer`. Those endpoints were removed (AZ-197 / cycle 2) and now return 404 — see FT-N-15 (AZ-197 routes) and FT-N-16 (cycle-2 routes) in `blackbox-tests.md`. --- @@ -71,21 +73,9 @@ --- -### NFT-SEC-05: Encryption Key Uniqueness +### NFT-SEC-05: Encryption Key Uniqueness — OBSOLETE (cycle 2, 2026-05-14) -**Summary**: Different users produce different encryption keys for the same resource. -**Traces to**: AC-19 - -**Steps**: - -| Step | Consumer Action | Expected Response | -|------|----------------|------------------| -| 1 | Upload test file | HTTP 200 | -| 2 | Download encrypted file as User A | Encrypted bytes A | -| 3 | Download same file as User B (different credentials + hardware) | Encrypted bytes B | -| 4 | Compare encrypted bytes A and B | Different | - -**Pass criteria**: Encrypted outputs differ between users +The `POST /resources/get/{dataFolder?}` endpoint that this test exercised was removed along with `Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` and `ResourcesService.GetEncryptedResource`. Per-user resource encryption is no longer part of the system. ID retained for traceability stability; do not regenerate the spec body until a full `/test-spec` rerun. --- diff --git a/_docs/02_document/tests/traceability-matrix.md b/_docs/02_document/tests/traceability-matrix.md index 8f9ef79..4c5a1ad 100644 --- a/_docs/02_document/tests/traceability-matrix.md +++ b/_docs/02_document/tests/traceability-matrix.md @@ -43,8 +43,9 @@ |----------|-----------|---------|-------------|-----------| | Acceptance Criteria (baseline) | 19 | 19 | 0 | 100% | | Acceptance Criteria (cycle 1) | 24 | 24 | 0 | 100% | +| Acceptance Criteria (cycle 2) | 6 | 6 | 0 | 100% | | Restrictions | 8 | 5 | 3 | 63% | -| **Total** | **51** | **48** | **3** | **94%** | +| **Total** | **57** | **54** | **3** | **95%** | ## Uncovered Items Analysis @@ -118,3 +119,21 @@ The matrix rows below are kept for ID stability but no longer reflect production | AC-11 (Subsequent hardware check validates) | Obsoleted by AZ-197 — endpoint removed | | AC-12 (Hardware mismatch returns code 40) | Obsoleted by AZ-197 — `ExceptionEnum` value removed | | AC-19 (Encryption key derived from email+password+hw) | Partially obsoleted — derivation is now `email + password` only | + +## Cycle 2 Cleanup (2026-05-14) — Obsolete Resource Endpoints Removed + +The encrypted-download and installer-download endpoints were removed as obsolete. Affected matrix rows below are kept for ID stability but the underlying behaviour is gone; they are superseded by FT-N-16 in `blackbox-tests.md`. + +| Removed surface | Endpoint(s) | Affected legacy entries | Status | +|-----------------|-------------|-------------------------|--------| +| Per-user encrypted resource download | `POST /resources/get/{dataFolder?}` | AC-14 (AES-256-CBC encryption), AC-15 (round-trip), AC-19 (key derivation), FT-P-09, FT-P-10 | **Reverted** — endpoint deleted; `Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` and `ResourcesService.GetEncryptedResource` deleted; `GetResourceRequest` DTO deleted; e2e tests `Encrypted_download_returns_octet_stream_and_non_empty_body` and `Encryption_round_trip_decrypt_matches_original_bytes` deleted from `ResourceTests.cs`; e2e test `Per_user_encryption_produces_distinct_ciphertext_for_same_file` deleted from `SecurityTests.cs`; `Azaion.Test/SecurityTest.cs` deleted (and the now-empty `Azaion.Test` project removed from the solution). | +| Installer download (production + staging) | `GET /resources/get-installer`, `GET /resources/get-installer/stage` | AC-23 (latest installer), `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` references | **Reverted** — endpoints deleted; `ResourcesService.GetInstaller` deleted; both config properties removed from `appsettings.json`, `.env.example`, `secrets/staging.public.env`, `secrets/production.public.env`, and `docker-compose.test.yml`. No e2e tests had been written for these endpoints, so no tests required removal. | + +| AC ID | Acceptance Criterion | Test IDs | Coverage | +|-------|---------------------|----------|----------| +| Cycle-2 AC-1 | `POST /resources/get/{dataFolder?}` returns 404 | FT-N-16 | Covered | +| Cycle-2 AC-2 | `GET /resources/get-installer` returns 404 | FT-N-16 | Covered | +| Cycle-2 AC-3 | `GET /resources/get-installer/stage` returns 404 | FT-N-16 | Covered | +| Cycle-2 AC-4 | `ExceptionEnum` no longer carries `WrongResourceName` (50); the gap is preserved | — | Build/CI invariant — verified by enum read | +| Cycle-2 AC-5 | `Azaion.Test` project no longer in solution; build is clean | — | Build invariant — `dotnet build Azaion.AdminApi.sln` clean post-cleanup | +| Cycle-2 AC-6 | E2E suite passes after the test deletions above | All e2e tests | Covered by Step 11 Run Tests post-cleanup (2026-05-14) | diff --git a/_docs/02_tasks/_dependencies_table.md b/_docs/02_tasks/_dependencies_table.md index 321c495..a09cdd6 100644 --- a/_docs/02_tasks/_dependencies_table.md +++ b/_docs/02_tasks/_dependencies_table.md @@ -1,8 +1,8 @@ # Dependencies Table -**Date**: 2026-05-13 (refreshed; original 2026-04-16) -**Total Tasks**: 11 (7 done test tasks + 4 active product tasks) -**Total Complexity Points**: 40 +**Date**: 2026-05-14 (refreshed; previous 2026-05-13) +**Total Tasks**: 19 (7 done test tasks + 12 active product tasks) +**Total Complexity Points**: 71 | Task | Name | Complexity | Dependencies | Epic | Status | |--------|-------------------------------|-----------:|-------------------------|--------|--------| @@ -17,9 +17,21 @@ | AZ-196 | register_device_endpoint | 2 | None | AZ-181 | todo | | AZ-197 | remove_hardware_id | 3 | None | AZ-181 | todo | | AZ-513 | classes_crud_routes | 3 | None | AZ-509 | todo | +| AZ-531 | refresh_token_flow | 5 | None | AZ-529 | todo | +| AZ-532 | asymmetric_signing_jwks | 5 | None | AZ-529 | todo | +| AZ-533 | mission_token_uav | 5 | AZ-531 | AZ-529 | todo | +| AZ-534 | totp_2fa_login | 5 | None (coord. AZ-531/537) | AZ-529 | todo | +| AZ-535 | logout_revocation | 3 | AZ-531 | AZ-529 | todo | +| AZ-536 | argon2id_password_hashing | 3 | None | AZ-530 | todo | +| AZ-537 | login_rate_limit_lockout | 3 | None (coord. AZ-536) | AZ-530 | todo | +| AZ-538 | cors_https_only_hsts | 2 | None | AZ-530 | todo | ## Notes -- AZ-513 added 2026-05-13 (cross-workspace prerequisite from `ui/` workspace AZ-512). Filed under epic AZ-509 (Cycle 3 — Auth bootstrap fix + classColors carve-out + admin class edit). +- **AZ-529 / AZ-530 added 2026-05-14**: two new epics covering the auth-mechanism modernization and a focused CMMC compliance pass. + - **AZ-529 — Auth Mechanism Modernization** (5 tasks, 23 pts): refresh-token flow, asymmetric signing + JWKS, mission tokens for UAV, TOTP 2FA, logout/revocation. AZ-531 is the foundation that AZ-533 and AZ-535 build on; AZ-532 is independent and can land first or in parallel. + - **AZ-530 — CMMC Compliance Hardening** (3 tasks, 8 pts): Argon2id password hashing, /login rate limit + lockout, CORS https-only + HSTS. All three are independent and shippable now; AZ-536 + AZ-537 both touch `UserService.ValidateUser` so land AZ-536 first. +- **MFA scope**: TOTP enrollment + login validation lives in admin only (AZ-534). Other services (satellite-provider, gps-denied, ui) consume the `amr` claim if they need step-up checks — they do NOT enforce MFA themselves. +- **Cross-workspace verifier work** (satellite-provider, gps-denied, ui must switch from HS256 shared secret to JWKS verification, plus add denylist polling) is intentionally **deferred** to per-workspace tickets, to be filed once admin's AZ-529 epic is close to shipping. +- AZ-513 added 2026-05-13 (cross-workspace prerequisite from `ui/` workspace AZ-512). Filed under epic AZ-509. - AZ-197 originally listed `Component: Admin API, Loader`; the Loader workspace was architecturally retired (see `suite/_docs/_repo-config.yaml` `unresolved:loader-retirement-arch-doc`) and the spec was adapted on 2026-05-13 to be admin-only. -- All four active tasks (AZ-183, AZ-196, AZ-197, AZ-513) are independent — no inter-task dependencies in this active set. diff --git a/_docs/02_tasks/todo/AZ-531_refresh_token_flow.md b/_docs/02_tasks/todo/AZ-531_refresh_token_flow.md new file mode 100644 index 0000000..9747257 --- /dev/null +++ b/_docs/02_tasks/todo/AZ-531_refresh_token_flow.md @@ -0,0 +1,84 @@ +# Refresh-Token Flow with Rotation + Reuse Detection + +**Task**: AZ-531_refresh_token_flow +**Name**: Refresh-token flow with rotation + reuse detection +**Description**: Replace single 4h JWT with short-lived (15m) access + opaque refresh token. Rotate refresh on every use; kill the session family on reuse-detection per OAuth 2.1 §6.1. Persists session state in a new `sessions` table — the foundation logout/revocation will build on. +**Complexity**: 5 points +**Dependencies**: None +**Component**: Admin API + Services + DataAccess +**Tracker**: AZ-531 +**Epic**: AZ-529 + +## Problem + +`/login` today returns a single 4-hour HS256 JWT (`AuthService.CreateToken`). There is no refresh, no logout, and no way to shorten the access lifetime without forcing users to re-enter credentials every few minutes. Stolen tokens are valid for the full 4 h with no remediation. + +## Outcome + +- `POST /login` returns `{ access_token, access_exp, refresh_token, refresh_exp }`. Access TTL = 15 min. Refresh TTL = 8 h sliding, 12 h absolute. +- `POST /token/refresh` accepts an opaque refresh token, **rotates** it (issues new access + new refresh, invalidates old refresh), and returns the same shape. +- Refresh-reuse detection: if an already-rotated refresh token is presented again, the entire session family is killed (per OAuth 2.1 §6.1). +- Refresh tokens are opaque random 32-byte base64url strings stored hashed in `sessions` table — never JWTs. +- Existing single-token `/login` callers (UI) get an additive shape; older clients that ignore the new fields keep working until they're updated. + +## Scope + +### Included + +- New `sessions` table (id, user_id, refresh_hash, family_id, issued_at, last_used_at, expires_at, revoked_at, revoked_reason, parent_session_id). +- `IRefreshTokenService` + impl in `Azaion.Services/`. +- `/token/refresh` minimal-API handler in `Azaion.AdminApi/Program.cs`. +- Update `AuthService.CreateToken` to take refresh-context and stamp `jti` + `sid` claims on access tokens (needed by AZ-535 logout ticket). +- Update `LoginRequest`/`LoginResponse` DTO shape in `Azaion.Common/Requests/`. +- Migration script for the `sessions` table. + +### Excluded + +- Asymmetric signing — see AZ-532. +- Logout endpoint — see AZ-535. This ticket only persists session state. +- 2FA enforcement on `/login` — see AZ-534. +- UI changes to consume the new shape — cross-workspace ticket filed once admin lands. + +## Acceptance Criteria + +**AC-1: /login returns dual tokens** +Given valid credentials +When `POST /login` is called +Then response body has non-empty `access_token` (JWT, exp ≈ now+15m ±60s) AND `refresh_token` (opaque ≥43 chars), and a session row exists. + +**AC-2: /token/refresh rotates the refresh token** +Given a valid refresh token +When `POST /token/refresh` is called with it +Then response returns a new access + new refresh; the old refresh becomes invalid; session row's `refresh_hash` is updated; `parent_session_id` chains to the previous row. + +**AC-3: Reuse-detection kills family** +Given refresh token R1 was rotated to R2 +When R1 is presented again +Then `POST /token/refresh` returns 401, every session in R1's family is marked `revoked_reason='reuse_detected'`, and R2 also stops working. + +**AC-4: Sliding + absolute expiry** +Given a refresh token issued 7 h 50 min ago +When used +Then rotation succeeds, sliding window extended; if same family is older than 12 h absolute since first issue, refresh fails 401. + +**AC-5: Refresh tokens are opaque, not JWT** +Given any refresh token from `/login` or `/token/refresh` +When decoded +Then it is not a JWT (no dot-separated base64url segments parse as a header/payload). Stored as SHA-256 hash, raw value never logged. + +## Blackbox Tests + +| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | +|--------|------------------------|-------------|-------------------|----------------| +| AC-1 | Seed user | POST /login | 200 with both tokens, exp ≈ now+15m | — | +| AC-2 | Refresh R1 from AC-1 | POST /token/refresh with R1 | New access + new refresh; R1 invalid | — | +| AC-3 | R1 rotated to R2 | POST /token/refresh with R1 again | 401; R2 also dead | — | +| AC-4 | Refresh issued 11h59m ago | POST /token/refresh | Rotation succeeds; same family at 12h+ → 401 | — | +| AC-5 | Refresh token from any path | Decode/parse | Not a JWT; DB stores SHA-256 | — | + +## Risks / Notes + +- `sessions` table needs an index on `(refresh_hash)` for O(1) lookup. +- Rotation must be transactional (insert new + invalidate old in one tx) to prevent race where two parallel refreshes both succeed. +- Coordinate with AZ-535 (logout) for shared session-table schema. +- Coordinate with AZ-534 (2FA) for which `amr` value gets stamped into the access token's claims. diff --git a/_docs/02_tasks/todo/AZ-532_asymmetric_signing_jwks.md b/_docs/02_tasks/todo/AZ-532_asymmetric_signing_jwks.md new file mode 100644 index 0000000..d78b4d1 --- /dev/null +++ b/_docs/02_tasks/todo/AZ-532_asymmetric_signing_jwks.md @@ -0,0 +1,81 @@ +# Asymmetric Signing (RS256/ES256) + JWKS Endpoint + +**Task**: AZ-532_asymmetric_signing_jwks +**Name**: Asymmetric signing (RS256/ES256) + JWKS endpoint +**Description**: Switch admin's JWT signing from shared-secret HS256 to ES256 (preferred) so verifiers hold only public keys. Expose a standard `GET /.well-known/jwks.json`. Verifiers can no longer mint tokens even if compromised; new verifiers can be added without secret distribution. +**Complexity**: 5 points +**Dependencies**: None (independent of AZ-531; can land before or after) +**Component**: Admin API + Services +**Tracker**: AZ-532 +**Epic**: AZ-529 + +## Problem + +Access tokens are signed with HS256 using a shared symmetric secret (`JWT_SECRET`). Every verifier (satellite-provider today, gps-denied + ui tomorrow) holds material that can mint valid admin tokens — a breach of any one verifier compromises the whole auth domain. Adding a new verifier requires distributing the secret out-of-band. + +## Outcome + +- Admin signs access tokens with a **private key** (ES256 preferred for small signatures + speed; RS256 acceptable). Public key lives nowhere outside the JWKS endpoint. +- `GET /.well-known/jwks.json` returns the active public key set with `kid` per key. Cache headers: `Cache-Control: public, max-age=3600` (verifiers cache, refresh hourly). +- Tokens carry `kid` in the header so verifiers select the right key during rotation overlap. +- Key material lives in admin's secrets dir (`secrets/jwt_signing_key.pem`) — NOT in env vars. +- Documented rotation procedure: generate new key → add to JWKS as second entry → wait verifier-cache TTL → switch signing to new `kid` → wait until all old-kid tokens expire → remove old from JWKS. + +## Scope + +### Included + +- ES256 keypair generation script in `scripts/` (one-time setup + rotation tool). +- `IJwtSigningKeyProvider` interface + file-backed impl loading from `secrets/`. +- Update `AuthService.CreateToken` to use asymmetric signing. +- New `GET /.well-known/jwks.json` minimal-API handler (anonymous, cacheable, `.AllowAnonymous()`). +- Update `appsettings.json` / `.env.example` to drop `JWT_SECRET` (keep temporarily as fallback for one release for rollback safety). +- Tests: round-trip sign/verify, JWKS payload shape, kid header presence, alg-confusion attack rejection. + +### Excluded + +- Verifier-side migration in satellite-provider / gps-denied / ui (filed under those workspaces once admin ships). +- Hardware HSM / KMS integration (file-backed PEM is sufficient for now; HSM is a future ticket). +- Mission-token specific signing path (handled in AZ-533; uses same key). + +## Acceptance Criteria + +**AC-1: Admin signs with ES256** +Given admin is configured with an ES256 keypair +When `POST /login` succeeds +Then the returned access token's header has `alg=ES256` and `kid` matching the active key. + +**AC-2: JWKS endpoint serves the public key** +Given a fresh admin instance +When `GET /.well-known/jwks.json` is called (no auth) +Then response is 200 with body `{ "keys": [ { "kty":"EC", "crv":"P-256", "kid":"...", "x":"...", "y":"...", "alg":"ES256", "use":"sig" } ] }`. `Cache-Control: public, max-age=3600`. + +**AC-3: Two-key overlap during rotation** +Given two valid signing keys are configured (kid-A active, kid-B inactive but kept) +When JWKS is fetched +Then both keys appear; tokens signed with kid-A still verify; switching active to kid-B starts producing kid-B tokens; both verify until kid-A is removed. + +**AC-4: Private key never leaves admin** +Given the JWKS endpoint +When response is inspected +Then no `d` field (private scalar for EC) or `p`/`q` (RSA private primes) appears. Only public components. + +**AC-5: alg-confusion attack rejected** +Given a forged token with `alg=HS256` and signature computed with the public key as the HMAC secret +When presented to a verifier configured for ES256 +Then verification fails. (Pin expected algorithm explicitly in `TokenValidationParameters.ValidAlgorithms`.) + +## Blackbox Tests + +| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | +|--------|------------------------|-------------|-------------------|----------------| +| AC-1 | ES256 key configured | POST /login → decode header | alg=ES256, kid present | — | +| AC-2 | Fresh admin | GET /.well-known/jwks.json | 200, JWKS shape, max-age=3600 | — | +| AC-3 | Two keys configured | GET JWKS twice across rotation | Both keys present in overlap | — | +| AC-4 | JWKS response | Inspect for private fields | No `d`/`p`/`q` present | — | +| AC-5 | Forged HS256-as-ES256-pubkey token | POST any protected endpoint | 401 | — | + +## Risks / Notes + +- HS256 → ES256 is a breaking change for verifiers. Coordinate the cutover: admin keeps signing HS256 in parallel for one release while verifiers add ES256 verification, then admin flips to ES256-only. +- Document the cutover in `_docs/02_document/architecture.md` (suite-level). diff --git a/_docs/02_tasks/todo/AZ-533_mission_token_uav.md b/_docs/02_tasks/todo/AZ-533_mission_token_uav.md new file mode 100644 index 0000000..69d63e5 --- /dev/null +++ b/_docs/02_tasks/todo/AZ-533_mission_token_uav.md @@ -0,0 +1,102 @@ +# Mission-Token Issuance for Disconnected UAV Operations + +**Task**: AZ-533_mission_token_uav +**Name**: Mission-token issuance for disconnected UAV operations +**Description**: New `POST /sessions/mission` endpoint that issues a single long-lived (≤11 h) access token for one specific flight. Narrowly scoped (`mission_id`, `aircraft_id`, `aud`), one-shot, auto-revoked on aircraft reconnect. Solves the "10 h offline UAV vs 15 min ground access token" tension without weakening interactive-session security. +**Complexity**: 5 points +**Dependencies**: AZ-531 (needs `sessions` table for revocation tracking). Can implement in parallel; final wiring depends on AZ-531. +**Component**: Admin API + Services + DataAccess +**Tracker**: AZ-533 +**Epic**: AZ-529 + +## Problem + +UAV missions can fly up to 10 h fully offline (no Starlink, no admin reachability). Standard short-lived access tokens (15 min) plus refresh-on-network are physically impossible during flight. Today's solution would be "set JWT lifetime to 4 h and pray", which is both too short for full missions and too long for ground operations — a single lifetime can't satisfy both. + +## Outcome + +- New endpoint `POST /sessions/mission` (auth: existing interactive access token, MFA proven within last 15 min by virtue of refresh chain). +- Body: `{ mission_id, aircraft_id, planned_duration_h, requested_scope }`. +- Returns: a single long-lived access token (no refresh) with custom claims: + +```json +{ + "sub": "", + "iss": "AzaionApi", + "aud": "satellite-provider", + "exp": "now + planned_duration_h + 1h", + "mission_id": "M-2026-05-14-042", + "aircraft_id": "UAV-117", + "valid_region": { "...bbox..." : "..." }, + "permissions": ["GPS"], + "sid": "", + "jti": "", + "token_class": "mission" +} +``` + +- Mission tokens are recorded in `sessions` table with `class='mission'` so logout/revocation works. +- On post-flight reconnect (any successful auth call from the same `aircraft_id`), all open mission sessions for that aircraft are auto-revoked. + +## Scope + +### Included + +- `MissionSessionRequest` / `MissionSessionResponse` DTOs in `Azaion.Common/Requests/`. +- Validation: `planned_duration_h` ∈ [0.1, 12]; `mission_id` matches `M-YYYY-MM-DD-NNN`; `aircraft_id` exists in users table with `Role=CompanionPC`. +- Auto-revoke-on-reconnect logic in middleware (cheap: index on `sessions(aircraft_id, class, revoked_at)`). +- Tests: happy path, scope-narrowing, max-duration cap, auto-revoke on next call. + +### Excluded + +- Hardware binding (mTLS / DPoP / `cnf` claim) — separate future ticket. This ticket gets the lifetime + scope right; hardware binding is a hardening pass. +- Verifier-side enforcement of `mission_id`/`valid_region`/`aircraft_id` claims — filed under satellite-provider once admin ships. +- Pre-flight ground station UX (file/load mission token onto UAV) — client/UI concern. + +## Acceptance Criteria + +**AC-1: Mission token issued with correct lifetime** +Given an authenticated pilot session and `planned_duration_h=9` +When `POST /sessions/mission` is called +Then response includes a single access token with `exp ≈ now + 10h` (±60s), no refresh token, `token_class="mission"`. + +**AC-2: Hard cap enforced** +Given `planned_duration_h=15` +When called +Then 400 with detail `"planned_duration_h must be ≤ 12"`. + +**AC-3: Scope claims present** +Given a request with `mission_id` and `aircraft_id` +When the returned token is decoded +Then `mission_id`, `aircraft_id`, `aud="satellite-provider"`, `permissions`, `sid`, `jti` all present. + +**AC-4: Auto-revoke on reconnect** +Given aircraft UAV-117 has an open mission session M-001 +When UAV-117 calls any `/token/refresh` or `/login` endpoint successfully +Then the M-001 mission session is marked `revoked_reason='post_flight_reconnect'` and that token stops working. + +**AC-5: Issued only against an authenticated session** +Given no auth header +When `POST /sessions/mission` is called +Then 401. + +**AC-6: Auth claim chain proven (MFA step-up)** +Given the requesting access token has `amr=["pwd"]` only (no MFA) +When `POST /sessions/mission` is called (after AZ-534 ships) +Then 403 with detail `"mission tokens require step-up MFA"`. Until AZ-534 ships, AC-6 is enforced as a TODO comment in code; do not block this ticket on AZ-534. + +## Blackbox Tests + +| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | +|--------|------------------------|-------------|-------------------|----------------| +| AC-1 | Pilot session, 9h request | POST /sessions/mission | exp ≈ now+10h, no refresh, class=mission | — | +| AC-2 | 15h request | POST /sessions/mission | 400 with cap message | — | +| AC-3 | Mission token from AC-1 | Decode claims | mission_id, aircraft_id, aud, sid, jti present | — | +| AC-4 | Open mission for UAV-117 | UAV-117 calls /token/refresh | Mission revoked, token dead | — | +| AC-5 | No auth header | POST /sessions/mission | 401 | — | +| AC-6 | amr=["pwd"] token (post-AZ-534) | POST /sessions/mission | 403 step-up required | — | + +## Risks / Notes + +- Long-lived tokens are dangerous if leaked. Hardware binding is the right long-term answer; document this as known-risk in `_docs/05_security/security_report.md`. +- The `valid_region` bbox is informational until satellite-provider enforces it. Document the planned enforcement in the cross-workspace coordination note. diff --git a/_docs/02_tasks/todo/AZ-534_totp_2fa_login.md b/_docs/02_tasks/todo/AZ-534_totp_2fa_login.md new file mode 100644 index 0000000..54aeb10 --- /dev/null +++ b/_docs/02_tasks/todo/AZ-534_totp_2fa_login.md @@ -0,0 +1,93 @@ +# TOTP-Based 2FA at Credential Login + +**Task**: AZ-534_totp_2fa_login +**Name**: TOTP-based 2FA at credential login +**Description**: Add RFC 6238 TOTP enrollment, two-step `/login` flow, and recovery codes. MFA validated only at credential login (not on each refresh); access tokens stamp `amr` claim so future verifiers can require step-up MFA. Per-user opt-in initially; can be made mandatory by role via config. +**Complexity**: 5 points +**Dependencies**: None (touches `/login` so coordinate merge with AZ-537 rate-limit + AZ-531 dual-token) +**Component**: Admin API + Services + DataAccess +**Tracker**: AZ-534 +**Epic**: AZ-529 + +## Problem + +`/login` accepts password-only auth. CMMC requires multi-factor authentication for privileged accounts. There is no second-factor support today. + +## Outcome + +- TOTP (RFC 6238) enrollment + validation flow at credential login. No SMS, no email codes — TOTP only (offline-friendly, phishing-resistant against bulk SMS attacks). +- Recovery codes (10 single-use codes shown once at enrollment) for device-loss recovery. +- 2FA validated **only at credential login** — NOT on every refresh. The refresh chain proves "MFA was done in this session" via the `amr` claim. +- Access tokens stamp `amr: ["pwd","mfa"]` when MFA was completed; `amr: ["pwd"]` if password-only (e.g. CompanionPC service accounts that don't have MFA enrolled). +- Per-user opt-in initially; admin policy can require MFA for `Role in (Admin, ApiAdmin)` from a config flag. + +## Scope + +### Included + +- New columns on `users`: `mfa_enabled (bool)`, `mfa_secret (text, encrypted)`, `mfa_recovery_codes (jsonb of hashed codes)`, `mfa_enrolled_at (timestamptz)`. +- `POST /users/me/mfa/enroll` — returns `{ secret, otpauth_url, qr_png_base64, recovery_codes }`. Requires authenticated session, requires re-auth via password in same request body. +- `POST /users/me/mfa/confirm` — body `{ code }`, completes enrollment by validating one TOTP code. +- `POST /users/me/mfa/disable` — body `{ password, code }`, removes secret and recovery codes. +- `/login` flow change: if user has `mfa_enabled=true`, return `{ mfa_required: true, mfa_token: }` instead of access+refresh; client then calls `POST /login/mfa` with `{ mfa_token, code }` to get the real tokens. +- Recovery-code consumption: a recovery code may substitute for a TOTP code at `/login/mfa`; consumed code is marked used (single-use). +- Audit log entries for: enroll, confirm, disable, login-via-MFA, login-via-recovery-code. +- TOTP library: prefer `Otp.NET` (mature, no transitive deps). Verify version compatibility with .NET 10. + +### Excluded + +- WebAuthn / FIDO2 / hardware-key MFA — future ticket. +- Per-action step-up MFA (re-prompt for sensitive operations) — future ticket. AZ-533 mission-token issuance will start enforcing `amr=["pwd","mfa"]` after this lands. +- Admin-side reset of another user's MFA ("my user lost their phone") — future ticket; for now they go through recovery codes or DB intervention. +- UI changes — cross-workspace ticket later. + +## Acceptance Criteria + +**AC-1: Enrollment returns a usable TOTP secret** +Given an authenticated user without MFA +When `POST /users/me/mfa/enroll` is called with the user's password +Then response includes a 32-character base32 `secret`, a valid `otpauth://` URL, a PNG QR (base64), and 10 recovery codes (≥12 chars each, base32). DB has `mfa_enabled=false` until confirm. + +**AC-2: Confirm completes enrollment** +Given the user scanned the QR and got a 6-digit code +When `POST /users/me/mfa/confirm` is called with the code +Then MFA is activated (`mfa_enabled=true`); subsequent logins require step 2. + +**AC-3: Login two-step flow** +Given a user with MFA enabled +When `POST /login` is called with valid credentials +Then response is 200 with `{ mfa_required: true, mfa_token, expires_in: 300 }` — no access/refresh yet. +When `POST /login/mfa` is called with `mfa_token` + valid TOTP code +Then access + refresh tokens are issued; access token's `amr=["pwd","mfa"]`. + +**AC-4: Recovery code works once** +Given the same MFA-enabled user +When `POST /login/mfa` is called with a recovery code instead of TOTP +Then login succeeds; `amr=["pwd","mfa","recovery"]`; the same code on the next login fails. + +**AC-5: Disable requires password + current code** +Given a user with MFA enabled +When `POST /users/me/mfa/disable` is called with password + a valid TOTP code +Then MFA is disabled; subsequent `/login` returns access+refresh directly without step 2. + +**AC-6: TOTP secret is encrypted at rest** +Given an enrolled user +When the `users.mfa_secret` column is read directly from Postgres +Then the value is ciphertext (uses the same encryption infra already in admin for sensitive fields). + +## Blackbox Tests + +| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | +|--------|------------------------|-------------|-------------------|----------------| +| AC-1 | Auth user, no MFA | POST /users/me/mfa/enroll | 200 with secret, otpauth, QR, 10 recovery codes | — | +| AC-2 | Enrollment in progress | POST /users/me/mfa/confirm with valid code | MFA activated | — | +| AC-3 | MFA-enabled user | POST /login then POST /login/mfa | Two-step flow; amr=[pwd,mfa] | — | +| AC-4 | MFA-enabled user | POST /login/mfa with recovery code | Success once; amr=[pwd,mfa,recovery]; second use fails | — | +| AC-5 | MFA-enabled user | POST /users/me/mfa/disable | MFA off; /login returns tokens directly | — | +| AC-6 | Enrolled user | Read users.mfa_secret directly from DB | Ciphertext, not plaintext base32 | — | + +## Risks / Notes + +- TOTP code reuse: a single 30-second code window can be replayed within those 30 seconds. Mitigate by tracking last-used-time per user (small DB write per login). Optional in this ticket; flag for next hardening pass if not in scope. +- The two-step `/login` change is a wire-shape change for clients. Coordinate with UI workspace via cross-workspace ticket once admin lands. +- Touches the same `/login` code path as AZ-537 (rate limit) and AZ-531 (dual-token). Land AZ-531 first (changes response shape), then AZ-537 (adds limiter middleware), then this ticket (adds the two-step branch). diff --git a/_docs/02_tasks/todo/AZ-535_logout_revocation.md b/_docs/02_tasks/todo/AZ-535_logout_revocation.md new file mode 100644 index 0000000..ac547fe --- /dev/null +++ b/_docs/02_tasks/todo/AZ-535_logout_revocation.md @@ -0,0 +1,82 @@ +# Logout Endpoint + Revocation Surface for Verifiers + +**Task**: AZ-535_logout_revocation +**Name**: Logout endpoint + revocation surface for verifiers +**Description**: Add `POST /logout`, `POST /logout/all`, admin-only `POST /sessions/{sid}/revoke`, and a `GET /sessions/revoked?since=` snapshot endpoint that verifiers (satellite-provider, gps-denied, ui) poll to maintain a local denylist. Without this, JWTs cannot be revoked before `exp`. +**Complexity**: 3 points +**Dependencies**: AZ-531 (needs the `sessions` table); coordinate `jti`/`sid` claim stamping +**Component**: Admin API + Services + DataAccess +**Tracker**: AZ-535 +**Epic**: AZ-529 + +## Problem + +With stateless JWT validation, logout doesn't actually exist. Calling `/logout` on admin can clear admin's session, but satellite-provider, gps-denied, and any other verifier keep accepting the same token until `exp`. There is no way to forcibly kick a session in real time (e.g. "GPS permission revoked, end the flight"). + +## Outcome + +- `POST /logout` endpoint: revokes the caller's current session (refresh + all access tokens minted from it). Idempotent. +- `POST /logout/all` endpoint: revokes every session for the caller's user (full "sign out everywhere"). +- `POST /sessions/{sid}/revoke` (admin-only): revoke any session by id ("GPS permission revoked, kill flight UAV-117 mission M-042"). +- Verifiers consume revocation via either: + - **Pull mode (default)**: `GET /sessions/revoked?since=` returns `[{ jti, sid, exp }]`. Verifiers poll every 30 s and maintain a local denylist with TTL = token's remaining lifetime. + - **Push mode (optional)**: a Redis pub/sub channel `auth:revoked` for sub-second propagation. Pull is mandatory; push is best-effort acceleration. + +## Scope + +### Included + +- `POST /logout`, `POST /logout/all`, `POST /sessions/{sid}/revoke` handlers in `Azaion.AdminApi/Program.cs`. +- `GET /sessions/revoked?since=` endpoint authenticated via service-to-service JWT issued to each verifier identity (each verifier has a dedicated `Role=Service` user). +- Update `sessions` table with `revoked_at`, `revoked_reason`, `revoked_by_user_id` (these columns may already be present from AZ-531; if so, this ticket only adds `revoked_by_user_id`). +- Snapshot endpoint must auto-prune entries whose `exp < now()` so the response stays bounded. +- Tests: logout works, all-logout works, admin-revoke works, revoked endpoint returns recent revocations and excludes expired. + +### Excluded + +- Verifier-side denylist consumption (per-verifier ticket, filed when admin ships). +- Redis pub/sub push channel — nice-to-have; pull-based snapshot is the contract. +- Per-permission revocation in real time (e.g. "revoke just GPS, keep session alive") — architecturally requires moving permissions out of the JWT; future ticket. + +## Acceptance Criteria + +**AC-1: /logout revokes the session** +Given a valid access + refresh token pair +When `POST /logout` is called with the access token +Then the session row is marked `revoked_at=now()`, `revoked_reason='user_logout'`. The refresh token stops working. + +**AC-2: /logout/all revokes every session for the user** +Given user U has 3 active sessions +When `POST /logout/all` is called from any one of them +Then all 3 sessions are revoked. + +**AC-3: Admin can revoke any session by id** +Given user U has session SID-X +When an Admin-role JWT calls `POST /sessions/SID-X/revoke` +Then SID-X is marked revoked with `revoked_by_user_id` = the admin's id. + +**AC-4: /sessions/revoked snapshot returns recent revocations** +Given 5 sessions revoked in the last hour, 2 of which already expired +When `GET /sessions/revoked?since=<1h-ago>` is called by an authenticated verifier +Then response is the 3 non-expired ones, with `[{ jti, sid, exp }]`. `Cache-Control: no-cache` (this is real-time data). + +**AC-5: Idempotent logout** +Given a session already revoked +When `POST /logout` is called again with the same token +Then 200 with `{ already_revoked: true }`. No DB write. + +## Blackbox Tests + +| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | +|--------|------------------------|-------------|-------------------|----------------| +| AC-1 | Active session | POST /logout | Session revoked, refresh dead | — | +| AC-2 | User with 3 sessions | POST /logout/all from any | All 3 revoked | — | +| AC-3 | Admin JWT, target SID-X | POST /sessions/SID-X/revoke | SID-X revoked with admin id | — | +| AC-4 | 5 revoked (2 expired) | GET /sessions/revoked?since=… | Returns 3 non-expired | — | +| AC-5 | Already-revoked session | POST /logout again | 200 already_revoked, no DB write | — | + +## Risks / Notes + +- The pull endpoint must NOT leak revocations across users to non-admin callers. Verifier identity is service-level (each verifier has a dedicated `Role=Service` user with read-revocations permission); they get the global feed. Regular users only see their own sessions if a future endpoint is added. +- 30 s polling means up to 30 s of "stale token works" after logout. Documented as acceptable; for sub-second, deploy the optional Redis push. +- Coordinate auto-prune cadence to keep snapshot < 5 KB even at high revocation rates. diff --git a/_docs/02_tasks/todo/AZ-536_argon2id_password_hashing.md b/_docs/02_tasks/todo/AZ-536_argon2id_password_hashing.md new file mode 100644 index 0000000..f77643e --- /dev/null +++ b/_docs/02_tasks/todo/AZ-536_argon2id_password_hashing.md @@ -0,0 +1,93 @@ +# Replace SHA-384 Password Hashing with Argon2id (Salted) + +**Task**: AZ-536_argon2id_password_hashing +**Name**: Replace SHA-384 password hashing with Argon2id (salted) +**Description**: Replace the unsalted single-pass SHA-384 in `Azaion.Services/Security.cs::ToHash` with Argon2id (RFC 9106), salted, memory-hard. PHC string format (self-describing — no separate salt column needed). Lazy migration: existing SHA-384 hashes re-hash on next successful login. +**Complexity**: 3 points +**Dependencies**: None +**Component**: Services + DataAccess +**Tracker**: AZ-536 +**Epic**: AZ-530 +**CMMC ref**: IA.L2-3.5.10 (cryptographic mechanisms to protect passwords) + +## Problem + +`Azaion.Services/Security.cs::ToHash` does: + +```csharp +public static string ToHash(this string str) => + Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(str))); +``` + +Used at `UserService.cs:43` (registration) and `UserService.cs:115` (login validation). This is **unsalted**, **fast**, **single-pass** SHA-384. + +Problems: +- Trivially attacked with rainbow tables (no salt). +- GPU bruteforce ≈ billions of guesses/sec. +- Identical passwords across users produce identical hashes (visible in DB dumps). +- Affects every `users` row in the central admin DB — including operator, admin, and CompanionPC device passwords. + +## Outcome + +- Replace `ToHash` with Argon2id (RFC 9106), salted, with conservative parameters (memory ≥ 64 MiB, iterations ≥ 3, parallelism ≥ 1). +- Each password hash stored in PHC string format: `$argon2id$v=19$m=65536,t=3,p=1$$` — self-describing, no separate salt column needed. +- **Lazy migration**: existing SHA-384 hashes stay in the DB. On next successful login (verified by re-hashing the submitted plaintext with SHA-384 and matching), the password is re-hashed with Argon2id and the row updated. Detect format by prefix (`$argon2id$` vs base64). +- For service accounts that never log in interactively (CompanionPC devices), provide an admin-side bulk-reset script that rotates their passwords during next provisioning cycle. + +## Scope + +### Included + +- Add `Konscious.Security.Cryptography.Argon2` (or `Isopoh.Cryptography.Argon2` — both pure C#) as a `Azaion.Services` dependency. Pin a specific version. +- Refactor `Security.cs`: `HashPassword(string)` returns PHC string; `VerifyPassword(string plaintext, string stored)` handles both formats and triggers re-hash for legacy SHA-384. +- Update `UserService.RegisterUser` to call `HashPassword`. +- Update `UserService.ValidateUser` to call `VerifyPassword` and on legacy-hash match, write the new Argon2id hash back transactionally before returning success. +- Update `_docs/05_security/security_report.md` to reflect the new state and the migration plan. +- Tests: hash format, verify happy path, verify legacy hash transparently re-hashes, verify wrong password fails for both formats, parameter sanity (m ≥ 64 MiB). + +### Excluded + +- Forced password reset on next login (not required — lazy migration covers humans; service accounts via separate provisioning). +- Pepper / HSM-bound hashing — future hardening pass. +- Algorithm agility framework ("add bcrypt support too") — not needed; Argon2id is the answer for the next 5+ years. + +## Acceptance Criteria + +**AC-1: New users get Argon2id hashes** +Given a fresh registration +When the row is inspected +Then `password_hash` starts with `$argon2id$v=19$m=`… and parameter parses confirm m ≥ 65536, t ≥ 3, p ≥ 1. + +**AC-2: Legacy SHA-384 hashes still validate** +Given a seed user with a SHA-384 hash from before this change +When they log in with the correct password +Then 200 — login succeeds. + +**AC-3: Successful legacy login transparently re-hashes** +After AC-2, when the same user's row is re-read +Then `password_hash` is now in Argon2id PHC format. The same plaintext continues to validate. + +**AC-4: Wrong password fails for both formats** +Given a user with a SHA-384 hash and a user with an Argon2id hash +When each tries to log in with the wrong password +Then both return 409 ExceptionEnum=WrongPassword (existing error semantics preserved). + +**AC-5: Verify is constant-time** +Given any stored hash +When `VerifyPassword` is called with various wrong passwords of different lengths +Then timing variance is not observable to a remote attacker (rely on the library's constant-time comparator; do NOT use `string ==`). + +## Blackbox Tests + +| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | +|--------|------------------------|-------------|-------------------|----------------| +| AC-1 | Fresh registration | Read users.password_hash | Starts with $argon2id$v=19$, m ≥ 65536 | NFT-SEC-NEW | +| AC-2 | Seed user with legacy SHA-384 hash | POST /login with correct pwd | 200 | — | +| AC-3 | After AC-2 | Read users.password_hash | Now Argon2id PHC format | — | +| AC-4 | Both hash formats | POST /login with wrong pwd | 409 WrongPassword | — | +| AC-5 | Various-length wrong pwds | Time the verify | No remotely-observable timing leak | — | + +## Risks / Notes + +- Argon2id with 64 MiB × 3 iterations costs ≈ 50-200 ms per verify on commodity hardware. Login latency increases noticeably (was ≈ 1 ms with SHA-384). This is the point — it makes bruteforce expensive. Document the new latency in security report. +- AZ-537 (rate limit + lockout) and this ticket touch the same code path (`UserService.ValidateUser`). Coordinate merge order — land Argon2id (this ticket) first since it changes the success path semantics, then AZ-537 layers on top. diff --git a/_docs/02_tasks/todo/AZ-537_login_rate_limit_lockout.md b/_docs/02_tasks/todo/AZ-537_login_rate_limit_lockout.md new file mode 100644 index 0000000..4967d55 --- /dev/null +++ b/_docs/02_tasks/todo/AZ-537_login_rate_limit_lockout.md @@ -0,0 +1,99 @@ +# /login Rate Limit + Account Lockout + +**Task**: AZ-537_login_rate_limit_lockout +**Name**: /login rate limit + account lockout +**Description**: Add ASP.NET Core sliding-window rate limiter on `/login` (per-IP and per-account) plus an account-lockout policy after 10 consecutive failures. Closes the unbounded credential-stuffing / password-spray surface. +**Complexity**: 3 points +**Dependencies**: None functionally; coordinate merge order with AZ-536 (both touch `UserService.ValidateUser`) +**Component**: Admin API + Services + DataAccess +**Tracker**: AZ-537 +**Epic**: AZ-530 +**CMMC ref**: AC.L2-3.1.8 (limit unsuccessful logon attempts) + +## Problem + +`Azaion.AdminApi/Program.cs:177` (`POST /login`) has no rate limiting and no account lockout. An attacker can: +- **Credential stuffing**: spray leaked username/password pairs from other breaches at unlimited RPS. +- **Password spray**: try one common password against every known account. +- **Targeted bruteforce**: hammer one account. + +Nothing in the request path slows them down. Combined with the SHA-384 hashing flaw (sister ticket AZ-536), this is high-severity. + +## Outcome + +- ASP.NET Core built-in rate limiter (`AddRateLimiter`) attached to `/login`: + - **Per-IP**: 10 attempts / 60 s (sliding window). Burst of 3. + - **Per-account** (keyed by submitted email, normalised lowercase): 5 attempts / 5 min. + - Both limits return 429 with `Retry-After` header when exceeded. +- **Account lockout**: after 10 consecutive failed logins for a single account, lock it for 15 min (configurable). Lockout state stored on `users` row (`lockout_until timestamptz`, `failed_login_count int`). Successful login resets the counter. +- Lockout takes precedence over rate limit (if account is locked, return 423 Locked even if request is within rate budget). +- Counters reset on successful login. + +## Scope + +### Included + +- New columns on `users`: `failed_login_count int default 0`, `lockout_until timestamptz null`. +- Migration script for the schema change. +- `RateLimiter` configuration in `Program.cs` (use built-in `AddSlidingWindowLimiter` for IP + account partitions). +- Update `UserService.ValidateUser` to: + - Reject early with 423 if `lockout_until > now()`. + - On wrong password: increment `failed_login_count`; if it hits the threshold, set `lockout_until = now() + 15min`. + - On success: zero the counter and clear `lockout_until`. +- `appsettings.json` keys for thresholds (`Auth:RateLimit:*`, `Auth:Lockout:MaxAttempts`, `Auth:Lockout:DurationMinutes`). +- Tests: rate-limit triggers 429, lockout triggers 423 even for correct password, success resets counter, lockout auto-expires after duration. +- Audit log entries for each lockout event (security-relevant). + +### Excluded + +- CAPTCHA challenge — not in scope; rate-limit + lockout is sufficient for CMMC L2. +- Distributed rate-limit store (Redis-backed limiter for multi-instance admin) — in-memory limiter is acceptable for current single-instance deploy. Document the upgrade path. +- Admin-side "unlock user" API — separate small ticket if needed; for now wait out the 15-min window or DB intervention. + +## Acceptance Criteria + +**AC-1: Per-IP rate limit triggers 429** +Given 11 `/login` requests from the same IP within 60 s +When the 11th is sent +Then response is 429 with a `Retry-After` header. + +**AC-2: Per-account rate limit triggers 429** +Given 6 `/login` requests for `alice@x.com` from 6 different IPs within 5 min +When the 6th is sent +Then response is 429 (account-key partition triggered). + +**AC-3: Account lockout after 10 failures** +Given `alice@x.com` has 9 consecutive wrong-password attempts (across IPs / time) +When the 10th wrong attempt arrives +Then `users.lockout_until = now() + 15min`. Subsequent attempts — even with the correct password — return 423 Locked until that time. + +**AC-4: Successful login resets the counter** +Given `alice@x.com` has 5 failed attempts +When she submits the correct password (within the rate-limit budget) +Then login succeeds and `failed_login_count = 0`, `lockout_until = NULL`. + +**AC-5: Lockout auto-expires** +Given `alice@x.com` is locked with `lockout_until = T` +When she submits the correct password at `T + 1s` +Then login succeeds. + +**AC-6: Audit log on lockout** +Given AC-3 fires +When the audit log is inspected +Then there is a `login_lockout` entry with `email`, `ip`, `timestamp`. + +## Blackbox Tests + +| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | +|--------|------------------------|-------------|-------------------|----------------| +| AC-1 | 11 requests from same IP in 60s | 11th POST /login | 429 with Retry-After | NFT-SEC-NEW | +| AC-2 | 6 requests for alice from 6 IPs in 5min | 6th POST /login | 429 | NFT-SEC-NEW | +| AC-3 | 10 wrong-pwd attempts | 11th attempt with correct pwd | 423 Locked | NFT-SEC-NEW | +| AC-4 | 5 failed attempts | Successful login | counter=0, lockout_until=NULL | — | +| AC-5 | Locked until T | Login at T+1s with correct pwd | 200 | — | +| AC-6 | AC-3 fires | Inspect audit log | login_lockout entry present | — | + +## Risks / Notes + +- DoS-as-a-service: an attacker can lock out a known target's account by spraying wrong passwords from many IPs. The per-account counter intentionally allows this (CMMC requires lockout regardless of source). Mitigate operationally with admin-side unlock; do not weaken the rule. +- AZ-536 (Argon2id hashing) and this ticket both modify `UserService.ValidateUser`. Coordinate merge order — land AZ-536 first since it changes the success path semantics; this ticket layers on top. diff --git a/_docs/02_tasks/todo/AZ-538_cors_https_only_hsts.md b/_docs/02_tasks/todo/AZ-538_cors_https_only_hsts.md new file mode 100644 index 0000000..1115828 --- /dev/null +++ b/_docs/02_tasks/todo/AZ-538_cors_https_only_hsts.md @@ -0,0 +1,95 @@ +# CORS — Drop HTTP Origin, Enforce HTTPS-Only + HSTS + +**Task**: AZ-538_cors_https_only_hsts +**Name**: CORS — drop http origin, enforce HTTPS-only + HSTS +**Description**: Remove `http://admin.azaion.com` from the CORS allow-list (currently combined with `AllowCredentials()`, which permits credentialed traffic over cleartext), enable HSTS in non-Development envs, and add HTTPS redirection as defence in depth. +**Complexity**: 2 points +**Dependencies**: None +**Component**: Admin API +**Tracker**: AZ-538 +**Epic**: AZ-530 +**CMMC ref**: SC.L2-3.13.8 (encrypt CUI in transit), SC.L2-3.13.11 (FIPS-validated cryptography) + +## Problem + +`Azaion.AdminApi/Program.cs` lines 117-127: + +```csharp +builder.Services.AddCors(options => +{ + options.AddPolicy("AdminCorsPolicy", policy => + { + policy.WithOrigins("https://admin.azaion.com", "http://admin.azaion.com") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + }); +}); +``` + +Allowing the `http://` origin together with `AllowCredentials()` means a browser will send cookies / `Authorization` headers to the admin API over cleartext from `http://admin.azaion.com`. Any LAN MITM (coffee shop wifi, compromised AP, ARP spoof) can capture the session. + +## Outcome + +- Drop `"http://admin.azaion.com"` from `WithOrigins`. Only `https://admin.azaion.com` remains. +- Enable HSTS via `app.UseHsts()` in non-Development environments. `max-age=31536000; includeSubDomains; preload`. +- Add `app.UseHttpsRedirection()` to bounce any cleartext request to HTTPS at the protocol layer (defence in depth — even if someone re-adds the http origin by accident, the redirect kicks in first). +- Verify dev workflow: any contributor who relied on `http://admin.azaion.com` locally must switch to `https://localhost:` (devcert is already in `secrets/`). + +## Scope + +### Included + +- One-line `WithOrigins` change. +- `UseHsts` + `UseHttpsRedirection` in `Program.cs`, gated to non-Development env to keep `dotnet watch` flow on http://localhost intact. +- Update `_docs/05_security/security_report.md` (close the finding). +- Update `_docs/02_document/architecture.md` if it documents the http allowance. +- Smoke test: cleartext origin returns CORS rejection in browser preflight. + +### Excluded + +- mTLS between services — separate ticket, larger scope. +- Cert pinning at clients — separate ticket. +- TLS 1.3 enforcement — already the Kestrel default in .NET 10; no action needed. + +## Acceptance Criteria + +**AC-1: http origin rejected by CORS** +Given a browser preflight `OPTIONS /login` with `Origin: http://admin.azaion.com` +When the response is inspected +Then no `Access-Control-Allow-Origin` header is returned (CORS denies the request). + +**AC-2: https origin still works** +Given a browser preflight `OPTIONS /login` with `Origin: https://admin.azaion.com` +When the response is inspected +Then `Access-Control-Allow-Origin: https://admin.azaion.com` is present and `Access-Control-Allow-Credentials: true`. + +**AC-3: HSTS header on prod responses** +Given the app runs with `ASPNETCORE_ENVIRONMENT=Production` +When any HTTPS request returns +Then response includes `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`. + +**AC-4: HTTP requests redirect to HTTPS** +Given the app runs with `ASPNETCORE_ENVIRONMENT=Production` +When `GET http://admin.azaion.com/health/live` is called +Then response is 307 to `https://admin.azaion.com/health/live`. + +**AC-5: Development env unchanged** +Given `ASPNETCORE_ENVIRONMENT=Development` +When `GET http://localhost:8080/health/live` is called +Then 200 (no HTTPS redirect, no HSTS). + +## Blackbox Tests + +| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | +|--------|------------------------|-------------|-------------------|----------------| +| AC-1 | Origin: http://admin.azaion.com | OPTIONS preflight | No ACAO header | NFT-SEC-NEW | +| AC-2 | Origin: https://admin.azaion.com | OPTIONS preflight | ACAO present, ACAC: true | — | +| AC-3 | Production env | Any HTTPS response | HSTS header present | NFT-SEC-NEW | +| AC-4 | Production env | GET http:// URL | 307 to https:// | — | +| AC-5 | Development env | GET http://localhost:8080/health/live | 200, no HSTS | — | + +## Risks / Notes + +- If any deployed UI build is pinned to `http://admin.azaion.com`, this change will break it. Verify the UI build's API base URL before merging. +- If a reverse proxy / load balancer terminates TLS upstream, ensure `app.UseForwardedHeaders` is correctly configured so `UseHttpsRedirection` doesn't loop. Document expected header config in `_docs/04_deploy/`. diff --git a/_docs/04_deploy/observability.md b/_docs/04_deploy/observability.md index 942cf8b..5a7002a 100644 --- a/_docs/04_deploy/observability.md +++ b/_docs/04_deploy/observability.md @@ -74,7 +74,7 @@ Achieved by adding `Serilog.Formatting.Compact.RenderedCompactJsonFormatter` to | Rule | Implementation | |------|----------------| -| Never log passwords | `LoginRequest.Password`, `RegisterUserRequest.Password`, `GetResourceRequest.Password`, the response body of `POST /devices` (plaintext one-shot password). Add a `[Serilog.Sensitive]`-style helper or a `Destructure.ByTransforming(t => …)` per DTO. | +| Never log passwords | `LoginRequest.Password`, `RegisterUserRequest.Password`, the response body of `POST /devices` (plaintext one-shot password). Add a `[Serilog.Sensitive]`-style helper or a `Destructure.ByTransforming(t => …)` per DTO. (`GetResourceRequest.Password` was previously listed; the DTO was deleted in cycle 2 with the encrypted-download endpoint.) | | Never log JWT tokens | The `/login` response body is logged today only by `BusinessExceptionHandler` on failure, which doesn't include the body. Verify in Step 7 that no request-logger middleware logs response bodies. | | Mask emails | Use last-4 + `@domain` form for INFO-level logs (`***123@example.com`); full email allowed at DEBUG only. The `BusinessExceptionHandler` log line `"Caught BusinessException: {Message}"` may include emails embedded in messages — tightened in Step 7. | | User IDs | `User.Id` is an opaque GUID — safe to log; use it instead of email in correlation. | @@ -100,7 +100,6 @@ Achieved by adding `Serilog.Formatting.Compact.RenderedCompactJsonFormatter` to | `business_exceptions_total` | Counter | `BusinessExceptionHandler` | `error_code` (the existing `ExceptionEnum`) | | `resource_upload_bytes_total` | Counter | `ResourcesService.SaveResource` | `data_folder` | | `resource_upload_failures_total` | Counter | same | `reason` | -| `resource_download_bytes_total` | Counter | `ResourcesService.GetEncryptedResource` | `data_folder` | | `detection_classes_total` | Gauge | refresh on CRUD | none | | `users_active_total` | Gauge | refresh on CRUD + on a 5-min timer | `role` | | Process / runtime | (auto) | `prometheus-net.DotNetRuntime` | gen0/1/2 GC, JIT, threadpool, etc. | @@ -111,7 +110,7 @@ CPU, RSS, file descriptors, network I/O — collected by **node-exporter** runni ### 3.4 Business Metrics -Mapped to the verified ACs in `_docs/02_document/tests/blackbox-tests.md`. Cycle-1 cut: `users_active_total` (AC-01..AC-12 user lifecycle) and `detection_classes_total` (AZ-513). Resource-related business metrics deferred until the resource flow is exercised by real users post-AZ-197. +Mapped to the verified ACs in `_docs/02_document/tests/blackbox-tests.md`. Cycle-1 cut: `users_active_total` (AC-01..AC-12 user lifecycle) and `detection_classes_total` (AZ-513). The previously planned `resource_download_bytes_total` was dropped in cycle 2 along with `ResourcesService.GetEncryptedResource` itself; only the upload-side counters remain. ### 3.5 Collection diff --git a/_docs/04_deploy/reports/deploy_status_report.md b/_docs/04_deploy/reports/deploy_status_report.md index bfb83bc..45e3762 100644 --- a/_docs/04_deploy/reports/deploy_status_report.md +++ b/_docs/04_deploy/reports/deploy_status_report.md @@ -80,8 +80,6 @@ API has no outbound calls to external SaaS APIs (no SSRF surface). | `ASPNETCORE_JwtConfig__Audience` | JWT `aud` claim | All | `Annotators/OrangePi/Admins` (appsettings) | appsettings or env override | | `ASPNETCORE_JwtConfig__TokenLifetimeHours` | Token TTL | All | `4` (appsettings) | Environment | | `ASPNETCORE_ResourcesConfig__ResourcesFolder` | File storage root | All | `Content` | Environment | -| `ASPNETCORE_ResourcesConfig__SuiteInstallerFolder` | Prod installer dir | All | `suite` | Environment | -| `ASPNETCORE_ResourcesConfig__SuiteStageInstallerFolder` | Stage installer dir | All | `suite-stage` | Environment | | `CI_COMMIT_SHA` | Build-time label → `AZAION_REVISION` env in container | Build only | (unset → `unknown`) | Woodpecker `$CI_COMMIT_SHA` | | `DEPLOY_HOST` | Remote target machine for `scripts/deploy.sh` | Deploy scripts | `admin.azaion.com` | Environment | | `DEPLOY_SSH_USER` | SSH user on `DEPLOY_HOST` | Deploy scripts | `root` | Environment | diff --git a/_docs/05_security/owasp_review.md b/_docs/05_security/owasp_review.md index 68a4717..0e54ea2 100644 --- a/_docs/05_security/owasp_review.md +++ b/_docs/05_security/owasp_review.md @@ -32,7 +32,18 @@ The pre-cycle-1 `security_approach.md` "Known Security Observations" list is rec | 5. No rate limiting on `/login` | **Still open** — F-8 | | 6. No audit trail for security-relevant operations | **Still open** — A09 PASS_WITH_WARNINGS | | 7. No HTTPS enforcement in code | **Still open** — F-13 | -| 8. Static encryption key salts hardcoded | **Partially resolved** — `Security.GetApiEncryptionKey` salt is still hardcoded but the AZ-197 removal of the `hwHash` component reduced surface area. (`ResourceColumnEncryption` was deleted along with the OTA revert.) | +| 8. Static encryption key salts hardcoded | **Resolved by cycle-2 cleanup** — `Security.GetApiEncryptionKey` was deleted entirely along with `EncryptTo` / `DecryptTo` and the encrypted-download endpoint. No hardcoded encryption-key salt remains in application code. (`ResourceColumnEncryption` was deleted along with the OTA revert.) | + +## Cycle-2 Cleanup Verdict (2026-05-14) + +The cycle-2 cleanup removed three obsolete endpoints (`POST /resources/get/{dataFolder?}`, `GET /resources/get-installer`, `GET /resources/get-installer/stage`) and their orphaned support code (`Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo`, `ResourcesService.GetEncryptedResource` / `GetInstaller`, `GetResourceRequest`, `WrongResourceName = 50`, `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder`). + +Net security impact: + +- **Observation 8 closed** (see table above) — the static encryption-key salt no longer exists in source. +- **Attack surface reduced** under A02 (Cryptographic Failures): no more application-layer encryption stack means no more mis-keying, mis-IVing, or padding-oracle exposure to maintain. The remaining cryptographic surface in this codebase is JWT signing (HMAC-SHA256, library-managed) and SHA-384 password hashing. +- **No new findings introduced.** Three endpoints fewer also means three fewer A01 / A05 surfaces to track. +- **F-2 (path traversal via `dataFolder`)** remains open — the upload / list / clear endpoints still take `dataFolder` and still concatenate it directly with `ResourcesFolder`. The cleanup did not change this. ## Cycle-1 Specific Verdict diff --git a/_docs/06_metrics/retro_2026-05-13.md b/_docs/06_metrics/retro_2026-05-13.md new file mode 100644 index 0000000..8a0f0e2 --- /dev/null +++ b/_docs/06_metrics/retro_2026-05-13.md @@ -0,0 +1,169 @@ +# Retrospective — 2026-05-13 (Cycle 1, end of cycle) + +**Mode**: cycle-end +**Cycle**: 1 +**Window**: 2026-04-16 (Phase A baseline) → 2026-05-13 (Phase B feature cycle complete + Deploy) +**Previous retro**: N/A — first retrospective + +## Implementation Summary + +| Metric | Phase A (baseline) | Phase B (cycle 1) | Total | +|--------|-------------------:|------------------:|------:| +| Total tasks | 7 | 4 | **11** | +| Total batches | 4 | 2 | **6** | +| Total complexity points | 29 | 11 | **40** | +| Avg tasks per batch | 1.75 | 2.0 | 1.83 | +| Avg complexity per batch | 7.25 | 5.5 | 6.67 | +| Tasks per task spec | — | — | 1 | + +Per-task complexity (Phase B): AZ-513 (3) + AZ-196 (2) + AZ-183 (3, reverted) + AZ-197 (3) = 11 points. + +## Quality Metrics + +### Code Review Results + +| Verdict | Count | % | +|---------|------:|--:| +| PASS | 5 | 83% | +| PASS_WITH_WARNINGS | 1 | 17% | +| FAIL | 0 | 0% | + +### Findings by Severity (code review only — security audit findings counted separately below) + +| Severity | Count | Source | +|----------|------:|--------| +| Critical | 0 | — | +| High | 0 | — | +| Medium | 1 | batch_05 F1 (race on sequential serial) | +| Low | 3 | batch_05 F2/F3/F4 (uniqueness, key rotation, default empty key) | + +### Findings by Category + +| Category | Count | Top Files | +|----------|------:|-----------| +| Bug | 1 | `Azaion.Services/UserService.cs` (RegisterDevice) | +| Maintainability | 3 | `Azaion.Services/ResourceUpdateService.cs` (×2), `Azaion.AdminApi/appsettings.json` | +| Spec-Gap | 0 | — | +| Security | 0 *(code review)* / 13 *(security audit)* | — | +| Performance | 0 | — | +| Style | 0 | — | +| Scope | 0 | — | + +### Security Audit (out-of-band, post-implementation) + +| Severity | Count | Status at end of cycle | +|----------|------:|------------------------| +| Critical | 0 | — | +| High | 3 | F-1 closed (OTA reverted), F-3 closed (UNIQUE INDEX), D-1 closed (Newtonsoft 13.0.4); 1 pre-existing (F-2 path traversal) deferred to AZ-516 | +| Medium | 5 | 0 closed in audit; recorded as AZ-517..AZ-520 | +| Low | 5 | 0 closed; recorded as AZ-521 (bundle) | + +> The audit found 1 **regression** introduced by cycle-1 work: F-1 (`/get-update` exposed plaintext encryption keys, AZ-183). Fix: full revert of AZ-183. F-3 was an amplification of a pre-existing race (`RegisterDevice` not having a UNIQUE INDEX); the audit closed it by adding `env/db/06_users_email_unique.sql` and consolidating `RegisterDevice` to delegate row insertion to `RegisterUser`. + +### Performance Test + +| Verdict | NFT thresholds met | Coverage gaps | +|---------|--------------------|---------------| +| PASS | 2/2 (NFT-PERF-01 login p95=33 ms vs 500 ms; NFT-PERF-04 user-list p95=152 ms vs 1000 ms) | NFT-PERF-02/03 obsolete (OTA reverted); no `/classes` perf coverage yet | + +### Deploy Audit (this step) + +| Drift | Severity | Resolved this cycle | Carried forward | +|-------|---------:|--------------------:|----------------:| +| A — host pulls `:latest`, CI never produces it | Medium | yes | — | +| B — no secret manager | Medium | yes (sops + age) | — | +| C — container runs as root | Medium | yes (`USER app`) | — | +| D — stale `.woodpecker/build-arm.yml` reference | Low | yes (doc + actual files audited) | — | +| E — perf script run-on-demand | Low | spec'd; auto-gating deferred | I | +| F — no vulnerable-dep gate | Low | yes (deps-audit step) | — | +| G — unused `docker.test/Dockerfile` | Low | yes (deleted) | — | +| H — TCP-only healthcheck in test compose | Low | yes (curl /health/live) | — | +| I — no coverage threshold | Low | — | yes | +| J — manual DB migrations | Low | — | yes | +| K — no metrics / tracing implemented | Medium | spec only | yes | +| L — no central log aggregator | Low | — | yes | +| M — no tracing exporter | Low | — | yes | +| N — no zero-downtime deploy | Medium | — | yes | +| O — no remote SSH wrapper | Low | — | yes | + +**7 resolved this cycle, 8 carried forward.** + +## Efficiency Metrics + +| Metric | Value | Notes | +|--------|------:|-------| +| Blocked tasks | 0 | — | +| Tasks requiring fixes after review | 0 | All findings deferred or descoped, none required cycle re-entry | +| Auto-fix attempts triggered | 0 | Across all 6 batches | +| Stuck agents | 0 | — | +| Reverts after main code shipped | 1 | **AZ-183** — same-day revert after security audit finding F-1 | +| Skipped tests with documented reason | 1 | AZ-195 AC-1 (DB recovery test needs Docker socket access) | +| Test pass rate (E2E suite, end of Step 7) | 44/44 | After Dockerfile + healthcheck changes | + +### Blocker Analysis + +No blockers, but two notable mid-cycle pivots: + +| Event | Type | Prevention idea | +|-------|------|------------------| +| User clarified mid-implement (2026-05-13) that the Loader is architecturally retired → AZ-197 was rescoped from cross-workspace to admin-only | Spec ambiguity discovered late | Add an "implicit assumptions" review gate to `new-task` Step 5 (Acceptance Criteria) that explicitly asks: which other workspaces does this touch? Are they still active? | +| Security audit found AZ-183 ships plaintext encryption keys → entire feature reverted same day | Threat model gap not caught at planning | Add a lightweight "what new authenticated endpoints / persistence does this introduce?" prompt to `new-task` Step 5; route any non-zero answer through a 5-minute threat-model check before complexity is finalized | + +## Structural Snapshot + +This is the first retro, so no delta computation. Snapshot persisted to `_docs/06_metrics/structure_2026-05-13.md` (placeholder — module-layout.md has 5 conceptual sub-components but only **one** ownership boundary in the registry, so cross-component edge counting is degenerate for this workspace). + +| Metric | Value | Source | +|--------|------:|--------| +| Components (registry) | 1 (`Admin API`) | `_docs/02_document/module-layout.md` | +| Conceptual sub-components | 5 | same | +| csproj projects | 5 | `Azaion.AdminApi.sln` (4 prod + 1 e2e) | +| Cycles in module graph | 0 | inspection (single deployable, no cross-component edges in the registry) | +| New Architecture violations this cycle | 0 | no `cumulative_review_batches_*.md` exists; verified by inspection of batch reviews — no Architecture-category findings | +| Resolved Architecture violations | 0 | — | +| Net Architecture delta | 0 | — | +| Public-API contract files (`_docs/02_document/contracts/`) | 0 | folder absent | +| Contract coverage % | n/a | n/a | + +> Contract files are not part of this project's documentation set today. If future cycles introduce them (e.g., as part of a UI ↔ admin contract test effort), this section will start carrying real coverage numbers. + +## Trend Comparison + +| Metric | Previous | Current | Change | +|--------|----------|--------:|--------| +| Pass rate | n/a | 83% (5/6) | n/a | +| Avg findings per batch | n/a | 0.67 | n/a | +| Reverts | n/a | 1 | n/a | +| Carried-forward operational drifts | n/a | 8 | n/a | + +## Top 3 Improvement Actions + +1. **Add a security threat-model micro-step to `new-task` Step 5 (Acceptance Criteria)** + - **What**: Two extra lines on every task spec — "New authenticated endpoints introduced: [list]" and "New persistent data introduced: [list]". If either is non-empty, the next sub-step is a 5-minute threat-model check (data flow, secrets exposure, replay surface). Output recorded in the task spec under `## Threat Model Notes`. + - **Impact**: catches the AZ-183-style "endpoint exposes plaintext key" class of regression at planning time, before the 3-pt budget is committed. Saves at least one cycle of implement → security-audit → revert per occurrence. + - **Effort**: low (skill text edit + template addition). + +2. **Adopt the `_cycleN_` batch-report naming convention starting cycle 2** + - **What**: Rename forward — every new batch report and code-review file in cycle 2+ uses `batch_NN_cycleM_report.md` and `batch_NN_cycleM_review.md`. Cycle-1 files stay as `batch_NN_report.md` for history. Update the `implement` skill's report-filename template. + - **Impact**: prevents silent overwrite of cycle-1 batch reports when cycle 2's `batch_07` lands (would currently collide with `batch_07_report.md` if that name was used). Already documented in the existing-code flow Step 10 — this enforces it. + - **Effort**: low (one edit in `.cursor/skills/implement/`). + +3. **File the 8 carried-forward deploy drifts as Jira tickets in cycle 2 backlog** + - **What**: I, J, K, L, M, N, O are real backlog items (coverage gates, automated migrations, metrics + tracing, central logs, exporter, zero-downtime deploy, remote SSH wrapper). They currently live only as references in `_docs/04_deploy/*.md`. Promote them to AZ-tickets with story points. + - **Impact**: makes operational debt visible alongside feature work; protects against silent erosion of the deploy plan over multiple cycles. + - **Effort**: medium (≈ 30 min of ticket creation + sizing). + +## Suggested Rule / Skill Updates + +| File | Change | Rationale | +|------|--------|-----------| +| `.cursor/skills/new-task/SKILL.md` | Add Step 5.5 — "Threat-Model Micro-Check" with the two prompts above | AZ-183 revert (cycle 1) | +| `.cursor/skills/implement/SKILL.md` | Update batch-report filename template to `batch_NN_cycleM_report.md` (and review file analogously) | Naming-collision risk on cycle 2 | +| `.cursor/rules/coderule.mdc` | Add bullet: "Do not reuse retired numeric error codes (gaps are intentional)" | Batch 6 deletes codes 40 and 45 from `ExceptionEnum` — needs a rule so cycle 2 reviewers know not to fill the gap | +| `_docs/04_deploy/`-derived backlog | New AZ-* tickets for drifts I, J, K, L, M, N, O | Top action 3 above | + +## Notes + +- **First retrospective.** No prior baseline; cycle 2 will be the first one with delta numbers. +- **Cycle health**: green. 0 FAIL verdicts, 0 stuck agents, 0 auto-fix attempts, 44/44 E2E tests pass after Step 7's code edits. The single revert (AZ-183) was caught by the next-step security audit and resolved before deploy — the system worked, but the goal of the threat-model micro-check is to catch it one step earlier. +- **Operator burden after this cycle**: the 8 carried-forward drifts represent ≈ 22 story points of follow-up infrastructure work (rough sizing — to be confirmed when filed as tickets per Top Action 3). diff --git a/_docs/06_metrics/structure_2026-05-13.md b/_docs/06_metrics/structure_2026-05-13.md new file mode 100644 index 0000000..e342d7d --- /dev/null +++ b/_docs/06_metrics/structure_2026-05-13.md @@ -0,0 +1,54 @@ +# Structural Snapshot — 2026-05-13 (end of Cycle 1) + +Source-of-truth references: +- Module layout: `_docs/02_document/module-layout.md` +- Solution: `Azaion.AdminApi.sln` + +## Component Registry (single deployable boundary) + +| ID | Name | csproj projects | Public REST surface | +|----|------|-----------------|---------------------| +| C-ADMIN-API | Admin API | Azaion.AdminApi, Azaion.Common, Azaion.Services, Azaion.Tests, Azaion.E2E | OpenAPI surface in `Azaion.AdminApi/swagger.json` | + +## Conceptual Sub-Components (within C-ADMIN-API) + +| ID | Name | Owning project(s) | Notes | +|----|------|-------------------|-------| +| SC-DATA | Data Layer | Azaion.Common (Database/, Entities/, Requests/) | linq2db, Postgres | +| SC-USER | User Management | Azaion.Services/UserService.cs | RegisterUser, RegisterDevice | +| SC-AUTH | Auth & Security | Azaion.Services/JwtService.cs, Authorization.cs, ResourcesService.cs | JWT, role policies, file-resource permission filter | +| SC-RES | Resource Management | Azaion.Services/ResourcesService.cs, FilesService.cs | Per-user file resources | +| SC-API | Admin API surface | Azaion.AdminApi/Program.cs (Minimal API endpoints) | All HTTP entry points | + +## Edge Count + +Cross-component edges in this workspace: **0** (registry has 1 component). + +Cross-conceptual-sub-component edges (informational only — these are not deployment boundaries): +- SC-API → SC-USER, SC-AUTH, SC-RES +- SC-USER → SC-DATA, SC-AUTH +- SC-RES → SC-DATA, SC-AUTH +- SC-AUTH → SC-DATA + +No cycles in either graph. + +## Architecture Findings This Cycle + +| Source | Findings flagged "Architecture" | +|--------|-------------------------------:| +| `batch_05_review.md` | 0 | +| `batch_06_review.md` | 0 | +| `cumulative_review_batches_*.md` | (file does not exist for cycle 1) | +| Security audit (`_docs/05_security/security_report.md`) | 0 in the Architecture category | + +**Net Architecture delta: 0.** + +## Public API Contract Coverage + +`_docs/02_document/contracts/` directory does **not** exist in this workspace. The OpenAPI surface in `Azaion.AdminApi/swagger.json` is the de-facto contract; consumer-driven contract tests are not yet adopted. + +If a future cycle introduces a `contracts/` folder, this snapshot will start tracking: +- Number of contract files +- Endpoints under contract / total endpoints +- Coverage percentage +- Delta vs previous snapshot diff --git a/_docs/LESSONS.md b/_docs/LESSONS.md new file mode 100644 index 0000000..a11c8c2 --- /dev/null +++ b/_docs/LESSONS.md @@ -0,0 +1,19 @@ +# Lessons Log + +A ring buffer of the last 15 actionable lessons extracted from retrospectives and incidents. +Downstream skills consume this file: +- `.cursor/skills/new-task/SKILL.md` (Step 2 Complexity Assessment) +- `.cursor/skills/plan/steps/06_work-item-epics.md` (epic sizing) +- `.cursor/skills/decompose/SKILL.md` (Step 2 task complexity) +- `.cursor/skills/autodev/SKILL.md` (Execution Loop step 0 — surface top 3 lessons) + +Categories: estimation · architecture · testing · dependencies · tooling · process + +--- + +- [2026-05-13] [process] Add a threat-model micro-check to `new-task` Step 5 — endpoints that expose persisted secrets or introduce new auth surface must be flagged at planning, not after a security audit (AZ-183 plaintext-key revert). + Source: _docs/06_metrics/retro_2026-05-13.md +- [2026-05-13] [tooling] Switch batch and review filenames to `batch_NN_cycleM_*.md` starting cycle 2 — the current `batch_NN_*.md` collides on the next cycle and silently overwrites prior history. + Source: _docs/06_metrics/retro_2026-05-13.md +- [2026-05-13] [process] File deploy-skill carry-forward drifts (I, J, K, L, M, N, O) as Jira tickets at the end of every Deploy step so operational debt stays visible and sized. + Source: _docs/06_metrics/retro_2026-05-13.md diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 4243254..7274c6f 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -2,13 +2,13 @@ ## Current Step flow: existing-code -step: 17 -name: Retrospective +step: 9 +name: New Task status: not_started sub_step: phase: 0 name: awaiting-invocation detail: "" retry_count: 0 -cycle: 1 +cycle: 2 tracker: jira diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 8370ecc..2151ab0 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -32,8 +32,6 @@ services: ConnectionStrings__AzaionDbAdmin: "Host=test-db;Port=5432;Database=azaion;Username=azaion_admin;Password=test_password" JwtConfig__Secret: "TestSecretKeyThatIsAtLeast32CharactersLong123!" ResourcesConfig__ResourcesFolder: "Content" - ResourcesConfig__SuiteInstallerFolder: "suite" - ResourcesConfig__SuiteStageInstallerFolder: "suite-stage" ports: - "8080:8080" volumes: diff --git a/e2e/Azaion.E2E/Tests/ResourceTests.cs b/e2e/Azaion.E2E/Tests/ResourceTests.cs index 739f4b7..5c14598 100644 --- a/e2e/Azaion.E2E/Tests/ResourceTests.cs +++ b/e2e/Azaion.E2E/Tests/ResourceTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Json; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using Azaion.E2E.Helpers; @@ -17,8 +16,6 @@ public sealed class ResourceTests PropertyNameCaseInsensitive = true }; - private const string TestUserPassword = "TestPass1234"; - private sealed record ErrorResponse(int ErrorCode, string Message); private readonly TestFixture _fixture; @@ -50,119 +47,6 @@ public sealed class ResourceTests } } - [Fact] - public async Task Encrypted_download_returns_octet_stream_and_non_empty_body() - { - // Arrange - var folder = $"restest-{Guid.NewGuid():N}"; - const string fileName = "secure.bin"; - var fileBytes = Encoding.UTF8.GetBytes("download-test-payload"); - string? email = null; - - try - { - using (var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken)) - { - using var upload = await admin.UploadFileAsync($"/resources/{folder}", fileBytes, fileName); - upload.EnsureSuccessStatusCode(); - } - - var candidateEmail = $"restest-{Guid.NewGuid():N}@azaion.com"; - using (var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken)) - { - using var createResp = await admin.PostAsync("/users", - new { Email = candidateEmail, Password = TestUserPassword, Role = 10 }); - createResp.EnsureSuccessStatusCode(); - } - - email = candidateEmail; - - using var loginClient = _fixture.CreateApiClient(); - var userToken = await loginClient.LoginAsync(email, TestUserPassword); - using var userClient = _fixture.CreateAuthenticatedClient(userToken); - - // Act - using var response = await userClient.PostAsync($"/resources/get/{folder}", - new { Password = TestUserPassword, FileName = fileName }); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Headers.ContentType?.MediaType.Should().Be("application/octet-stream"); - var body = await response.Content.ReadAsByteArrayAsync(); - body.Should().NotBeEmpty(); - } - finally - { - if (email is not null) - { - using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); - using var del = await adminCleanup.DeleteAsync($"/users/{Uri.EscapeDataString(email)}"); - del.EnsureSuccessStatusCode(); - } - - using var adminClear = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); - using var clear = await adminClear.PostAsync($"/resources/clear/{folder}", new { }); - clear.EnsureSuccessStatusCode(); - } - } - - [Fact] - public async Task Encryption_round_trip_decrypt_matches_original_bytes() - { - // Arrange - var folder = $"restest-{Guid.NewGuid():N}"; - const string fileName = "roundtrip.bin"; - var original = Enumerable.Range(0, 128).Select(i => (byte)i).ToArray(); - const string password = "RoundTrip123"; - string? email = null; - - try - { - using (var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken)) - { - using var upload = await admin.UploadFileAsync($"/resources/{folder}", original, fileName); - upload.EnsureSuccessStatusCode(); - } - - var candidateEmail = $"roundtrip-{Guid.NewGuid():N}@azaion.com"; - using (var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken)) - { - using var createResp = await admin.PostAsync("/users", - new { Email = candidateEmail, Password = password, Role = 10 }); - createResp.EnsureSuccessStatusCode(); - } - - email = candidateEmail; - - using var loginClient = _fixture.CreateApiClient(); - var userToken = await loginClient.LoginAsync(email, password); - using var userClient = _fixture.CreateAuthenticatedClient(userToken); - - // Act - using var download = await userClient.PostAsync($"/resources/get/{folder}", - new { Password = password, FileName = fileName }); - download.EnsureSuccessStatusCode(); - var encrypted = await download.Content.ReadAsByteArrayAsync(); - var decrypted = DecryptResourcePayload(encrypted, email!, password); - - // Assert - decrypted.Should().Equal(original); - } - finally - { - if (email is not null) - { - using var adminCleanup = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); - using var del = await adminCleanup.DeleteAsync($"/users/{Uri.EscapeDataString(email)}"); - del.EnsureSuccessStatusCode(); - } - - using var adminClear = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); - using var clear = await adminClear.PostAsync($"/resources/clear/{folder}", new { }); - clear.EnsureSuccessStatusCode(); - } - } - [Fact] public async Task Upload_without_file_is_rejected_with_400_or_409_and_60_on_conflict() { @@ -182,22 +66,4 @@ public sealed class ResourceTests err!.ErrorCode.Should().Be(60); } } - - private static byte[] DecryptResourcePayload(byte[] encrypted, string email, string password) - { - var apiKey = Convert.ToBase64String(SHA384.HashData( - Encoding.UTF8.GetBytes($"{email}-{password}-#%@AzaionKey@%#---"))); - var aesKey = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey)); - - if (encrypted.Length <= 16) - throw new InvalidOperationException("Encrypted payload too short."); - - using var aes = Aes.Create(); - aes.Key = aesKey; - aes.IV = encrypted.AsSpan(0, 16).ToArray(); - aes.Mode = CipherMode.CBC; - aes.Padding = PaddingMode.PKCS7; - using var decryptor = aes.CreateDecryptor(); - return decryptor.TransformFinalBlock(encrypted, 16, encrypted.Length - 16); - } } diff --git a/e2e/Azaion.E2E/Tests/SecurityTests.cs b/e2e/Azaion.E2E/Tests/SecurityTests.cs index 4e8c269..dda5b20 100644 --- a/e2e/Azaion.E2E/Tests/SecurityTests.cs +++ b/e2e/Azaion.E2E/Tests/SecurityTests.cs @@ -55,10 +55,6 @@ public sealed class SecurityTests using (var r = await client.DeleteAsync($"/users/{Uri.EscapeDataString(probeEmail)}")) r.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - - using (var r = await client.PostAsync("/resources/get", - new { password = "irrelevant1", fileName = "f.bin" })) - r.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] @@ -141,63 +137,6 @@ public sealed class SecurityTests response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } - [Fact] - public async Task Per_user_encryption_produces_distinct_ciphertext_for_same_file() - { - // Arrange - var folder = $"sectest-{Guid.NewGuid():N}"; - var fileName = $"enc-{Guid.NewGuid():N}.bin"; - var payload = Encoding.UTF8.GetBytes($"secret-{Guid.NewGuid()}"); - var email1 = $"{Guid.NewGuid():N}@sectest.example.com"; - var email2 = $"{Guid.NewGuid():N}@sectest.example.com"; - const string password = "TestPwd12345"; - - try - { - foreach (var email in new[] { email1, email2 }) - { - var reg = JsonSerializer.Serialize(new { email, password, role = 10 }, JsonOptions); - using var create = await _fixture.HttpClient.PostAsync("/users", - new StringContent(reg, Encoding.UTF8, "application/json")); - create.IsSuccessStatusCode.Should().BeTrue(); - } - - using (var adminUpload = _fixture.CreateAuthenticatedClient(_fixture.AdminToken)) - { - using var up = await adminUpload.UploadFileAsync($"/resources/{folder}", payload, fileName); - up.IsSuccessStatusCode.Should().BeTrue(); - } - - async Task DownloadForAsync(string email) - { - using var api = _fixture.CreateApiClient(); - var token = await api.LoginAsync(email, password); - api.SetAuthToken(token); - using var get = await api.PostAsync($"/resources/get/{folder}", - new { password, fileName }); - get.IsSuccessStatusCode.Should().BeTrue(); - return await get.Content.ReadAsByteArrayAsync(); - } - - // Act - var bytes1 = await DownloadForAsync(email1); - var bytes2 = await DownloadForAsync(email2); - - // Assert - bytes1.Should().NotBeEquivalentTo(bytes2); - } - finally - { - using var clearResponse = await _fixture.HttpClient.PostAsync($"/resources/clear/{folder}", - new StringContent("", Encoding.UTF8, "application/json")); - - foreach (var email in new[] { email1, email2 }) - { - using var _ = await _fixture.HttpClient.DeleteAsync($"/users/{Uri.EscapeDataString(email)}"); - } - } - } - [Fact] public async Task Hardware_endpoints_are_removed_AZ_197() { diff --git a/secrets/production.public.env b/secrets/production.public.env index 315d693..458dd86 100644 --- a/secrets/production.public.env +++ b/secrets/production.public.env @@ -8,8 +8,6 @@ ASPNETCORE_JwtConfig__Issuer=AzaionApi ASPNETCORE_JwtConfig__Audience=Annotators/OrangePi/Admins ASPNETCORE_JwtConfig__TokenLifetimeHours=4 ASPNETCORE_ResourcesConfig__ResourcesFolder=Content -ASPNETCORE_ResourcesConfig__SuiteInstallerFolder=suite -ASPNETCORE_ResourcesConfig__SuiteStageInstallerFolder=suite-stage DEPLOY_CONTAINER_NAME=azaion.api DEPLOY_HOST_PORT=4000 diff --git a/secrets/staging.public.env b/secrets/staging.public.env index 9851dff..40236ce 100644 --- a/secrets/staging.public.env +++ b/secrets/staging.public.env @@ -9,8 +9,6 @@ ASPNETCORE_JwtConfig__Issuer=AzaionApi ASPNETCORE_JwtConfig__Audience=Annotators/OrangePi/Admins ASPNETCORE_JwtConfig__TokenLifetimeHours=4 ASPNETCORE_ResourcesConfig__ResourcesFolder=Content -ASPNETCORE_ResourcesConfig__SuiteInstallerFolder=suite -ASPNETCORE_ResourcesConfig__SuiteStageInstallerFolder=suite-stage # Deploy-host plumbing. DEPLOY_CONTAINER_NAME=azaion.api