refactor: remove obsolete resource download and installer endpoints
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

- 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 <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 04:17:55 +03:00
parent c7b297de83
commit 3a925b9b0f
60 changed files with 1202 additions and 982 deletions
-2
View File
@@ -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.
+4 -7
View File
@@ -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
-42
View File
@@ -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<CreateDetectionClassRequest> validator,
IDetectionClassService detectionClassService, CancellationToken ct) =>
+1 -3
View File
@@ -7,9 +7,7 @@
},
"AllowedHosts": "*",
"ResourcesConfig": {
"ResourcesFolder": "Content",
"SuiteInstallerFolder": "suite",
"SuiteStageInstallerFolder": "suite-stage"
"ResourcesFolder": "Content"
},
"JwtConfig": {
"Issuer": "AzaionApi",
-3
View File
@@ -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,
}
+1 -3
View File
@@ -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!;
}
}
@@ -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;
}
}
@@ -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<GetResourceRequest>
{
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));
}
}
-25
View File
@@ -8,8 +8,6 @@ namespace Azaion.Services;
public interface IResourcesService
{
(string?, Stream?) GetInstaller(bool isStage);
Task<Stream> GetEncryptedResource(string? dataFolder, string fileName, string key, CancellationToken cancellationToken = default);
Task SaveResource(string? dataFolder, IFormFile data, CancellationToken cancellationToken = default);
Task<IEnumerable<string>> ListResources(string? dataFolder, string? search, CancellationToken cancellationToken = default);
void ClearFolder(string? dataFolder);
@@ -24,29 +22,6 @@ public class ResourcesService(IOptions<ResourcesConfig> 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<Stream> 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)
-51
View File
@@ -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);
}
}
-23
View File
@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Azaion.Services\Azaion.Services.csproj" />
</ItemGroup>
</Project>
-110
View File
@@ -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<Stream> 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);
}
}
+11 -11
View File
@@ -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)
@@ -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)
<!-- StreamExtensions removed in cycle 2 (2026-05-14): only consumer was the deleted SecurityTest. -->
- `QueryableExtensions` — conditional LINQ Where filter (used by UserService)
## Consumers
@@ -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 |
|-----------|-------|
@@ -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.
```
@@ -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
@@ -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<string>` | 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)
@@ -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 |
@@ -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/`)
+4 -5
View File
@@ -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/`.
@@ -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]
```
+5 -8
View File
@@ -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) | `./<Csproj>/` | namespace-root types in each csproj | `Azaion.Test/`, `e2e/Azaion.E2E/` |
| C# (.NET) | `./` (this workspace, legacy flat layout) | `./<Csproj>/` | namespace-root types in each csproj | `e2e/Azaion.E2E/` |
## Notes
+14 -12
View File
@@ -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)
@@ -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<ExceptionEnum>()`. Messages are retrieved by dictionary lookup with fallback to `ToString()`.
@@ -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.
@@ -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.)
@@ -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`.
@@ -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`
@@ -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.
@@ -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<Stream> 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<IEnumerable<string>> 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<ResourcesConfig>` — folder paths
- `ILogger<ResourcesService>` — 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`.
+10 -21
View File
@@ -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/`).
@@ -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.)
@@ -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.
@@ -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.
+10 -74
View File
@@ -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.
---
+32 -40
View File
@@ -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
+10 -20
View File
@@ -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.
---
+20 -1
View File
@@ -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) |
+17 -5
View File
@@ -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.
@@ -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.
@@ -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).
@@ -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": "<pilot-or-aircraft-user-id>",
"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": "<session-id>",
"jti": "<token-id>",
"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.
@@ -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: <short-lived JWT> }` 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).
@@ -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=<ts>` 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=<unix-ts>` 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=<ts>` 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.
@@ -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$<salt-b64>$<hash-b64>` — 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.
@@ -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.
@@ -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:<port>` (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/`.
+2 -3
View File
@@ -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>(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>(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
@@ -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 |
+12 -1
View File
@@ -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
+169
View File
@@ -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).
+54
View File
@@ -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
+19
View File
@@ -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
+3 -3
View File
@@ -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
-2
View File
@@ -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:
-134
View File
@@ -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);
}
}
-61
View File
@@ -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<byte[]> 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()
{
-2
View File
@@ -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
-2
View File
@@ -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