mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 05:41:09 +00:00
refactor: remove obsolete resource download and installer endpoints
- Deleted the `POST /resources/get/{dataFolder?}` and `GET /resources/get-installer` endpoints as part of the architectural shift towards simplified resource management.
- Removed associated methods and configurations, including `ResourcesService.GetEncryptedResource`, `ResourcesService.GetInstaller`, and related properties in `ResourcesConfig`.
- Cleaned up environment variables and configuration files to reflect the removal of installer-related settings.
- Eliminated the `GetResourceRequest` DTO and its validator, along with the `WrongResourceName` error code.
- Updated documentation to clarify the changes in resource handling and the retirement of per-user file encryption.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ResourcesConfig": {
|
||||
"ResourcesFolder": "Content",
|
||||
"SuiteInstallerFolder": "suite",
|
||||
"SuiteStageInstallerFolder": "suite-stage"
|
||||
"ResourcesFolder": "Content"
|
||||
},
|
||||
"JwtConfig": {
|
||||
"Issuer": "AzaionApi",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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]
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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/`.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user