mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 08:21:10 +00:00
[AZ-197] Remove hardware ID binding from resource flow
Sealed-Jetson + SaaS architecture eliminates the credential-reuse-across-
machines threat that motivated hardware fingerprint binding. The binding's
only remaining effect was a real production failure mode on legitimate
hardware events.
Production:
- Drop PUT /users/hardware/set and POST /resources/check.
- Simplify POST /resources/get/{dataFolder?} (no Hardware field).
- Remove CheckHardwareHash, UpdateHardware, Security.GetHWHash.
- GetApiEncryptionKey signature: (email, password) — no hardwareHash.
- Drop SetHWRequest DTO and Hardware property from GetResourceRequest.
- Remove HardwareIdMismatch (40) and BadHardware (45) ExceptionEnum
entries; numeric codes left as a gap, not for reuse.
Wire-compat policy: drop entirely (no Loader; no in-flight legacy
clients). Stale callers will see 404s, which is the right loud failure.
Tombstones:
- User.Hardware DB column kept (nullable, unused) — separate cleanup
ticket for the migration per workspace "no rename without confirmation".
- User.LastLogin is now never written by app code (only writer was inside
the deleted CheckHardwareHash); flagged in batch_06_review for a future
ticket.
Tests:
- Delete e2e HardwareBindingTests (165 lines) and Azaion.Test
UserServiceTest (sole test was CheckHardwareHashTest).
- Drop Hardware payloads + /resources/check preconditions from e2e
ResourceTests, SecurityTests, ResilienceTests; drop hardwareId arg
from Azaion.Test SecurityTest.
- Add SecurityTests.Hardware_endpoints_are_removed_AZ_197 (AC-2 regression
asserting both removed routes return 404).
Docs:
- architecture.md: System Context note, ADR-003 new key formula, ADR-004
retired with rationale.
- diagrams/flows/flow_hardware_check.md: tombstoned.
Also archives the four batch-1+batch-2 task files into _docs/02_tasks/done/
(file moves were missed by the batch_05 commit).
Code review: PASS — see _docs/03_implementation/reviews/batch_06_review.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -172,12 +172,6 @@ app.MapGet("/users",
|
|||||||
.RequireAuthorization(apiAdminPolicy)
|
.RequireAuthorization(apiAdminPolicy)
|
||||||
.WithSummary("List users by criteria");
|
.WithSummary("List users by criteria");
|
||||||
|
|
||||||
app.MapPut("/users/hardware/set",
|
|
||||||
async ([FromBody]SetHWRequest request, IUserService userService, ICache cache, CancellationToken ct) =>
|
|
||||||
await userService.UpdateHardware(request.Email, request.Hardware, ct: ct))
|
|
||||||
.RequireAuthorization(apiAdminPolicy)
|
|
||||||
.WithSummary("Sets user's hardware");
|
|
||||||
|
|
||||||
app.MapPut("/users/queue-offsets/set",
|
app.MapPut("/users/queue-offsets/set",
|
||||||
async ([FromBody]SetUserQueueOffsetsRequest request, IUserService userService, CancellationToken ct)
|
async ([FromBody]SetUserQueueOffsetsRequest request, IUserService userService, CancellationToken ct)
|
||||||
=> await userService.UpdateQueueOffsets(request.Email, request.Offsets, ct))
|
=> await userService.UpdateQueueOffsets(request.Email, request.Offsets, ct))
|
||||||
@@ -229,20 +223,18 @@ app.MapPost("/resources/clear/{dataFolder?}",
|
|||||||
|
|
||||||
app.MapPost("/resources/get/{dataFolder?}", //Need to have POST method for secure password
|
app.MapPost("/resources/get/{dataFolder?}", //Need to have POST method for secure password
|
||||||
async ([FromBody]GetResourceRequest request, [FromRoute]string? dataFolder, IAuthService authService,
|
async ([FromBody]GetResourceRequest request, [FromRoute]string? dataFolder, IAuthService authService,
|
||||||
IUserService userService, IResourcesService resourcesService, CancellationToken ct) =>
|
IResourcesService resourcesService, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var user = await authService.GetCurrentUser();
|
var user = await authService.GetCurrentUser();
|
||||||
if (user == null)
|
if (user == null)
|
||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
|
|
||||||
var hwHash = await userService.CheckHardwareHash(user, request.Hardware);
|
var key = Security.GetApiEncryptionKey(user.Email, request.Password);
|
||||||
|
|
||||||
var key = Security.GetApiEncryptionKey(user.Email, request.Password, hwHash);
|
|
||||||
var stream = await resourcesService.GetEncryptedResource(dataFolder, request.FileName, key, ct);
|
var stream = await resourcesService.GetEncryptedResource(dataFolder, request.FileName, key, ct);
|
||||||
|
|
||||||
return Results.File(stream, "application/octet-stream", request.FileName);
|
return Results.File(stream, "application/octet-stream", request.FileName);
|
||||||
}).RequireAuthorization()
|
}).RequireAuthorization()
|
||||||
.WithSummary("Gets encrypted by users Password and HardwareHash resources. POST method for secure password");
|
.WithSummary("Gets encrypted by user's Password resource. POST method for secure password");
|
||||||
|
|
||||||
app.MapGet("/resources/get-installer",
|
app.MapGet("/resources/get-installer",
|
||||||
async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) =>
|
async (IAuthService authService, IResourcesService resourcesService, CancellationToken ct) =>
|
||||||
@@ -271,16 +263,6 @@ app.MapGet("/resources/get-installer/stage",
|
|||||||
.WithSummary("Gets latest installer");
|
.WithSummary("Gets latest installer");
|
||||||
|
|
||||||
|
|
||||||
app.MapPost("/resources/check",
|
|
||||||
async (CheckResourceRequest request, IAuthService authService, IUserService userService) =>
|
|
||||||
{
|
|
||||||
var user = await authService.GetCurrentUser();
|
|
||||||
if (user == null)
|
|
||||||
throw new UnauthorizedAccessException();
|
|
||||||
await userService.CheckHardwareHash(user, request.Hardware);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapPost("/classes",
|
app.MapPost("/classes",
|
||||||
async (CreateDetectionClassRequest request, IValidator<CreateDetectionClassRequest> validator,
|
async (CreateDetectionClassRequest request, IValidator<CreateDetectionClassRequest> validator,
|
||||||
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
IDetectionClassService detectionClassService, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -39,12 +39,6 @@ public enum ExceptionEnum
|
|||||||
[Description("User account is disabled.")]
|
[Description("User account is disabled.")]
|
||||||
UserDisabled = 38,
|
UserDisabled = 38,
|
||||||
|
|
||||||
[Description("Hardware mismatch! You are not authorized to access this resource from this hardware.")]
|
|
||||||
HardwareIdMismatch = 40,
|
|
||||||
|
|
||||||
[Description("Hardware should be not empty.")]
|
|
||||||
BadHardware = 45,
|
|
||||||
|
|
||||||
[Description("Wrong resource file name.")]
|
[Description("Wrong resource file name.")]
|
||||||
WrongResourceName = 50,
|
WrongResourceName = 50,
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,9 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Azaion.Common.Requests;
|
namespace Azaion.Common.Requests;
|
||||||
|
|
||||||
public class CheckResourceRequest
|
|
||||||
{
|
|
||||||
public string Hardware { get; set; } = null!;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GetResourceRequest
|
public class GetResourceRequest
|
||||||
{
|
{
|
||||||
public string Password { get; set; } = null!;
|
public string Password { get; set; } = null!;
|
||||||
public string Hardware { get; set; } = null!;
|
|
||||||
public string FileName { get; set; } = null!;
|
public string FileName { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,13 +17,9 @@ public class GetResourceRequestValidator : AbstractValidator<GetResourceRequest>
|
|||||||
.WithErrorCode(nameof(ExceptionEnum.PasswordLengthIncorrect))
|
.WithErrorCode(nameof(ExceptionEnum.PasswordLengthIncorrect))
|
||||||
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.PasswordLengthIncorrect));
|
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.PasswordLengthIncorrect));
|
||||||
|
|
||||||
RuleFor(r => r.Hardware)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.BadHardware));
|
|
||||||
|
|
||||||
RuleFor(r => r.FileName)
|
RuleFor(r => r.FileName)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
.WithErrorCode(nameof(ExceptionEnum.WrongResourceName))
|
.WithErrorCode(nameof(ExceptionEnum.WrongResourceName))
|
||||||
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.WrongResourceName));
|
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.WrongResourceName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace Azaion.Common.Requests;
|
|
||||||
|
|
||||||
public class SetHWRequest
|
|
||||||
{
|
|
||||||
public string Email { get; set; } = null!;
|
|
||||||
public string? Hardware { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SetHWRequestValidator : AbstractValidator<SetHWRequest>
|
|
||||||
{
|
|
||||||
public SetHWRequestValidator()
|
|
||||||
{
|
|
||||||
RuleFor(r => r.Email).NotEmpty()
|
|
||||||
.WithErrorCode(ExceptionEnum.EmailLengthIncorrect.ToString())
|
|
||||||
.WithMessage(_ => BusinessException.GetMessage(ExceptionEnum.EmailLengthIncorrect));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,11 +11,8 @@ public static class Security
|
|||||||
public static string ToHash(this string str) =>
|
public static string ToHash(this string str) =>
|
||||||
Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(str)));
|
Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(str)));
|
||||||
|
|
||||||
public static string GetHWHash(string hardware) =>
|
public static string GetApiEncryptionKey(string email, string password) =>
|
||||||
$"Azaion_{hardware}_%$$$)0_".ToHash();
|
$"{email}-{password}-#%@AzaionKey@%#---".ToHash();
|
||||||
|
|
||||||
public static string GetApiEncryptionKey(string email, string password, string? hardwareHash) =>
|
|
||||||
$"{email}-{password}-{hardwareHash}-#%@AzaionKey@%#---".ToHash();
|
|
||||||
|
|
||||||
public static async Task EncryptTo(this Stream inputStream, Stream toStream, string key, CancellationToken cancellationToken = default)
|
public static async Task EncryptTo(this Stream inputStream, Stream toStream, string key, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ public interface IUserService
|
|||||||
Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct = default);
|
Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct = default);
|
||||||
Task<User> ValidateUser(LoginRequest request, CancellationToken ct = default);
|
Task<User> ValidateUser(LoginRequest request, CancellationToken ct = default);
|
||||||
Task<User?> GetByEmail(string? email, CancellationToken ct = default);
|
Task<User?> GetByEmail(string? email, CancellationToken ct = default);
|
||||||
Task UpdateHardware(string email, string? hardware = null, CancellationToken ct = default);
|
|
||||||
Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default);
|
Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default);
|
||||||
Task<IEnumerable<User>> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct = default);
|
Task<IEnumerable<User>> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct = default);
|
||||||
Task<string> CheckHardwareHash(User user, string hardware, CancellationToken ct = default);
|
|
||||||
Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default);
|
Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default);
|
||||||
Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct = default);
|
Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct = default);
|
||||||
Task RemoveUser(string email, CancellationToken ct = default);
|
Task RemoveUser(string email, CancellationToken ct = default);
|
||||||
@@ -119,16 +117,6 @@ public class UserService(IDbFactory dbFactory, ICache cache) : IUserService
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
public async Task UpdateHardware(string email, string? hardware = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await dbFactory.RunAdmin(async db =>
|
|
||||||
{
|
|
||||||
await db.Users.UpdateAsync(x => x.Email == email,
|
|
||||||
u => new User { Hardware = hardware }, token: ct);
|
|
||||||
});
|
|
||||||
cache.Invalidate(User.GetCacheKey(email));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default)
|
public async Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await dbFactory.RunAdmin(async db =>
|
await dbFactory.RunAdmin(async db =>
|
||||||
@@ -155,35 +143,6 @@ public class UserService(IDbFactory dbFactory, ICache cache) : IUserService
|
|||||||
u => u.Role == searchRole)
|
u => u.Role == searchRole)
|
||||||
.ToListAsync(token: ct));
|
.ToListAsync(token: ct));
|
||||||
|
|
||||||
public async Task<string> CheckHardwareHash(User user, string hardware, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var requestHWHash = Security.GetHWHash(hardware);
|
|
||||||
|
|
||||||
//For the new users Hardware would be empty, fill it with actual hardware on the very first request
|
|
||||||
if (string.IsNullOrEmpty(user.Hardware))
|
|
||||||
{
|
|
||||||
await UpdateHardware(user.Email, hardware, ct);
|
|
||||||
cache.Invalidate(User.GetCacheKey(user.Email));
|
|
||||||
await UpdateLastLoginDate(user, ct);
|
|
||||||
return requestHWHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
var userHWHash = Security.GetHWHash(user.Hardware);
|
|
||||||
if (userHWHash != requestHWHash)
|
|
||||||
throw new BusinessException(ExceptionEnum.HardwareIdMismatch);
|
|
||||||
await UpdateLastLoginDate(user, ct);
|
|
||||||
return userHWHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateLastLoginDate(User user, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await dbFactory.RunAdmin(async db =>
|
|
||||||
await db.Users.UpdateAsync(x => x.Email == user.Email, u => new User
|
|
||||||
{
|
|
||||||
LastLogin = DateTime.UtcNow
|
|
||||||
}, ct));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default)
|
public async Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await dbFactory.RunAdmin(async db =>
|
await dbFactory.RunAdmin(async db =>
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ public class SecurityTest
|
|||||||
" sakdhvb kasjdhbv kjasdhv kjhas";
|
" sakdhvb kasjdhbv kjasdhv kjhas";
|
||||||
var email = "user@azaion.com";
|
var email = "user@azaion.com";
|
||||||
var password = "testpw";
|
var password = "testpw";
|
||||||
var hardwareId = "test_hardware_id";
|
|
||||||
|
|
||||||
var key = Security.GetApiEncryptionKey(email, password, hardwareId);
|
var key = Security.GetApiEncryptionKey(email, password);
|
||||||
|
|
||||||
var encryptedStream = new MemoryStream();
|
var encryptedStream = new MemoryStream();
|
||||||
await StringToStream(testString).EncryptTo(encryptedStream, key);
|
await StringToStream(testString).EncryptTo(encryptedStream, key);
|
||||||
@@ -49,9 +48,8 @@ public class SecurityTest
|
|||||||
{
|
{
|
||||||
var username = "user@azaion.com";
|
var username = "user@azaion.com";
|
||||||
var password = "testpw";
|
var password = "testpw";
|
||||||
var hardwareId = "test_hardware_id";
|
|
||||||
|
|
||||||
var key = Security.GetApiEncryptionKey(username, password, hardwareId);
|
var key = Security.GetApiEncryptionKey(username, password);
|
||||||
|
|
||||||
var largeFilePath = "large.txt";
|
var largeFilePath = "large.txt";
|
||||||
var largeFileDecryptedPath = "large_decrypted.txt";
|
var largeFileDecryptedPath = "large_decrypted.txt";
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
using Azaion.Common.Configs;
|
|
||||||
using Azaion.Common.Database;
|
|
||||||
using Azaion.Services;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Azaion.Test;
|
|
||||||
|
|
||||||
public class UserServiceTest
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task CheckHardwareHashTest()
|
|
||||||
{
|
|
||||||
var dbFactory = new DbFactory(new OptionsWrapper<ConnectionStrings>(new ConnectionStrings
|
|
||||||
{
|
|
||||||
AzaionDb = "Host=188.245.120.247;Port=4312;Database=azaion;Username=azaion_reader;Password=A@1n_zxre@d!only@$Az",
|
|
||||||
AzaionDbAdmin = "Host=188.245.120.247;Port=4312;Database=azaion;Username=azaion_admin;Password=Az@1on_Oddmin$$@r"
|
|
||||||
}));
|
|
||||||
var userService = new UserService(dbFactory, new MemoryCache());
|
|
||||||
var user = await userService.GetByEmail("spielberg@azaion.com");
|
|
||||||
if (user == null)
|
|
||||||
throw new Exception("User not found");
|
|
||||||
|
|
||||||
var res = await userService.CheckHardwareHash(user,
|
|
||||||
"CPU: AMD Ryzen 9 3900XT 12-Core Processor. GPU: Microsoft Remote Display Adapter. Memory: 67037080. DriveSerial: PHMB746301G6480DGN _00000001.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
## 1. System Context
|
## 1. System Context
|
||||||
|
|
||||||
**Problem being solved**: Azaion Suite requires a centralized admin API to manage users, assign roles, bind hardware to user accounts, and securely distribute encrypted software resources (DLLs, AI models, installers) to authorized devices.
|
**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**:
|
**System boundaries**:
|
||||||
- **Inside**: User management, authentication (JWT), role-based authorization, file-based resource storage with per-user AES encryption, hardware fingerprint validation.
|
- **Inside**: User management, authentication (JWT), role-based authorization, file-based resource storage with per-user AES encryption.
|
||||||
- **Outside**: Client applications (Azaion Suite desktop app, admin web panel at admin.azaion.com), PostgreSQL database, server filesystem for resource storage.
|
- **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).
|
||||||
|
|
||||||
**External systems**:
|
**External systems**:
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@
|
|||||||
|--------|-----------------|-----------|---------|
|
|--------|-----------------|-----------|---------|
|
||||||
| PostgreSQL | Database (linq2db) | Both | User data persistence |
|
| PostgreSQL | Database (linq2db) | Both | User data persistence |
|
||||||
| Server filesystem | File I/O | Both | Resource file storage and retrieval |
|
| Server filesystem | File I/O | Both | Resource file storage and retrieval |
|
||||||
| Azaion Suite client | REST API | Inbound | Resource download, hardware check, login |
|
| Azaion Suite client | REST API | Inbound | Resource download, login |
|
||||||
| Admin web panel (admin.azaion.com) | REST API | Inbound | User management, resource upload |
|
| Admin web panel (admin.azaion.com) | REST API | Inbound | User management, resource upload |
|
||||||
|
|
||||||
## 2. Technology Stack
|
## 2. Technology Stack
|
||||||
@@ -60,7 +62,7 @@
|
|||||||
|
|
||||||
| Entity | Description | Owned By Component |
|
| Entity | Description | Owned By Component |
|
||||||
|--------|-------------|--------------------|
|
|--------|-------------|--------------------|
|
||||||
| User | System user with email, password hash, hardware binding, role, config | 01 Data Layer |
|
| User | System user with email, password hash, role, config (legacy `Hardware` column tombstoned per AZ-197) | 01 Data Layer |
|
||||||
| UserConfig | JSON-serialized per-user configuration (queue offsets) | 01 Data Layer |
|
| UserConfig | JSON-serialized per-user configuration (queue offsets) | 01 Data Layer |
|
||||||
| RoleEnum | Authorization role hierarchy (None → ApiAdmin) | 01 Data Layer |
|
| RoleEnum | Authorization role hierarchy (None → ApiAdmin) | 01 Data Layer |
|
||||||
| ExceptionEnum | Business error code catalog | Common Helpers |
|
| ExceptionEnum | Business error code catalog | Common Helpers |
|
||||||
@@ -72,7 +74,7 @@
|
|||||||
**Data flow summary**:
|
**Data flow summary**:
|
||||||
- Client → API → UserService → PostgreSQL: user CRUD operations
|
- Client → API → UserService → PostgreSQL: user CRUD operations
|
||||||
- Client → API → ResourcesService → Filesystem: resource upload/download
|
- Client → API → ResourcesService → Filesystem: resource upload/download
|
||||||
- Client → API → Security → ResourcesService: encrypted resource retrieval (key derived from user credentials + hardware)
|
- Client → API → Security → ResourcesService: encrypted resource retrieval (key derived from user email + password; hardware-hash component removed in AZ-197)
|
||||||
|
|
||||||
## 5. Integration Points
|
## 5. Integration Points
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ No explicit availability, latency, throughput, or recovery targets found in the
|
|||||||
- General `[Authorize]` — any authenticated user
|
- General `[Authorize]` — any authenticated user
|
||||||
|
|
||||||
**Data protection**:
|
**Data protection**:
|
||||||
- At rest: Resources encrypted with AES-256-CBC using per-user derived key (email + password + hardware hash)
|
- 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).
|
||||||
- In transit: HTTPS (assumed, not enforced in code)
|
- In transit: HTTPS (assumed, not enforced in code)
|
||||||
- Secrets management: Environment variables (`ASPNETCORE_*` prefix)
|
- Secrets management: Environment variables (`ASPNETCORE_*` prefix)
|
||||||
|
|
||||||
@@ -140,19 +142,26 @@ No explicit availability, latency, throughput, or recovery targets found in the
|
|||||||
|
|
||||||
### ADR-003: Per-User Resource Encryption
|
### ADR-003: Per-User Resource Encryption
|
||||||
|
|
||||||
**Context**: Resources (DLLs, AI models) must be delivered only to authorized hardware.
|
**Context**: Resources (DLLs, AI models) must be delivered only to authorized users.
|
||||||
|
|
||||||
**Decision**: Resources are encrypted at download time using AES-256-CBC with a key derived from the user's email, password, and hardware hash. The client must know all three to decrypt.
|
**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.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
### ADR-004: Hardware Fingerprint Binding
|
> **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.
|
||||||
|
|
||||||
**Context**: Resources should only be usable on a specific physical machine.
|
### ADR-004: Hardware Fingerprint Binding — RETIRED (AZ-197)
|
||||||
|
|
||||||
**Decision**: On first resource access, the user's hardware fingerprint string is stored. Subsequent accesses compare the hash of the provided hardware against the stored value.
|
**Original context**: Resources should only be usable on a specific physical machine.
|
||||||
|
|
||||||
**Consequences**: Ties resources to a single device. Hardware changes require admin intervention to reset. The raw hardware string is stored in the DB; only the hash is compared.
|
**Original decision**: On first resource access, the user's hardware fingerprint string was stored. Subsequent accesses compared the hash of the provided hardware against the stored value.
|
||||||
|
|
||||||
|
**Retirement decision (2026-05-13, AZ-197)**: The threat model that motivated this binding (credential reuse across machines via desktop installers) no longer applies:
|
||||||
|
|
||||||
|
- **Edge devices** ship as **fTPM-secured Jetsons** (secure boot, fTPM-protected key storage, no user filesystem access, no installer redistribution). Hardware identity is anchored in the fTPM, not in a SHA-384 of CPU/GPU/Memory/DriveSerial strings.
|
||||||
|
- **Server / desktop access** is **SaaS-only** (browser → admin API). There is no installer to copy and no hardware fingerprint to take.
|
||||||
|
|
||||||
|
The binding's only remaining effect was a real production failure mode (`HardwareIdMismatch`, error code 40) on legitimate hardware events. AZ-197 removed `CheckHardwareHash`, `UpdateHardware`, `Security.GetHWHash`, the `PUT /users/hardware/set` and `POST /resources/check` endpoints, and the `Hardware` field from `GetResourceRequest`. The `User.Hardware` DB column is a nullable tombstone (no migration in AZ-197; separate ticket if/when the column is dropped).
|
||||||
|
|
||||||
### ADR-005: linq2db over Entity Framework
|
### ADR-005: linq2db over Entity Framework
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
# Flow: Hardware Check
|
# Flow: Hardware Check — OBSOLETE
|
||||||
|
|
||||||
|
> **Removed in AZ-197 (2026-05-13).**
|
||||||
|
>
|
||||||
|
> The `POST /resources/check` endpoint, the `UserService.CheckHardwareHash` method, the `HardwareIdMismatch` (40) and `BadHardware` (45) error codes, and the hardware-hash component of `Security.GetApiEncryptionKey` no longer exist. Resource downloads no longer require a hardware fingerprint.
|
||||||
|
>
|
||||||
|
> See `_docs/03_implementation/batch_06_report.md` and the AZ-197 task spec for context. Devices ship as fTPM-secured Jetsons or via SaaS; per-machine credential binding is no longer the relevant threat model.
|
||||||
|
>
|
||||||
|
> This file is retained as a tombstone so historical references resolve. Do not link to it from new docs.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
Start([POST /resources/check]) --> GetUser[AuthService.GetCurrentUser]
|
Start([POST /resources/check — REMOVED]) --> Removed[Endpoint deleted in AZ-197]
|
||||||
GetUser --> CheckNull{User null?}
|
|
||||||
CheckNull -->|Yes| Unauth[401 Unauthorized]
|
|
||||||
CheckNull -->|No| CheckHW[UserService.CheckHardwareHash]
|
|
||||||
CheckHW --> HasHW{User has stored hardware?}
|
|
||||||
HasHW -->|No - first time| StoreHW[Store hardware string in DB]
|
|
||||||
StoreHW --> UpdateLogin[Update last_login]
|
|
||||||
UpdateLogin --> ReturnHash([Return hwHash])
|
|
||||||
HasHW -->|Yes| CompareHash{Hashes match?}
|
|
||||||
CompareHash -->|Yes| UpdateLogin2[Update last_login]
|
|
||||||
UpdateLogin2 --> ReturnHash2([Return hwHash])
|
|
||||||
CompareHash -->|No| Mismatch([409: HardwareIdMismatch])
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Remove Hardware ID Binding
|
||||||
|
|
||||||
|
**Task**: AZ-197_remove_hardware_id
|
||||||
|
**Name**: Remove hardware ID binding from resource flow (admin-side cleanup)
|
||||||
|
**Description**: Remove `CheckHardwareHash`, `UpdateHardware`, `HardwareService`, the `PUT /users/hardware/set` endpoint, and the hardware-hash component of API encryption-key derivation. The threat this protected against (credential reuse across machines via desktop installers) no longer exists in the target architecture.
|
||||||
|
**Complexity**: 3 points
|
||||||
|
**Dependencies**: None
|
||||||
|
**Component**: Admin API
|
||||||
|
**Tracker**: AZ-197
|
||||||
|
**Epic**: AZ-181
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The `Hardware` field on `User` and the `CheckHardwareHash` flow were designed to bind a user account to a specific physical machine, preventing credential reuse across machines when users had desktop installers.
|
||||||
|
|
||||||
|
The target architecture has eliminated that threat:
|
||||||
|
|
||||||
|
- **Edge devices** ship as **secured Jetsons with fTPM** (secure boot, fTPM-protected key storage, no user filesystem access, no desktop installers distributed). Hardware identity is anchored in the fTPM, not in a SHA-384 of CPU/GPU/Memory/DriveSerial strings.
|
||||||
|
- **Server / desktop access** uses the **SaaS** path (browser → admin API). There is no installer to copy and no hardware fingerprint to take.
|
||||||
|
- The Loader component itself has been **architecturally retired** (Scenario X = Watchtower + rclone + flight-gate; see `suite/_docs/_repo-config.yaml` `unresolved:loader-retirement-arch-doc` and the assumptions log entries dated 2026-04-19). Provisioning was relocated from `loader/scripts/` to `suite/_infra/provisioning/`. There is no `loader/` workspace in the suite anymore, so this ticket is now **purely admin-side**.
|
||||||
|
|
||||||
|
The hardware binding therefore adds:
|
||||||
|
- Unnecessary complexity in the encryption-key derivation chain.
|
||||||
|
- A real production failure mode (`HardwareIdMismatch`, error code 40) on legitimate drive-replacement / fTPM-attested rotations.
|
||||||
|
- A maintenance cost on every endpoint and DTO that still carries the `Hardware` field.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Resource download flow no longer requires a hardware fingerprint.
|
||||||
|
- API encryption-key derivation simplified to email + password only.
|
||||||
|
- All admin-API hardware-binding code paths removed.
|
||||||
|
- Hardware-binding tests removed (unit + e2e); other tests updated to stop sending `Hardware`.
|
||||||
|
- DB column `User.Hardware` left in place but nullable and unused — no migration in this ticket (separate cleanup ticket if/when desired).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Included — Admin API production code
|
||||||
|
|
||||||
|
- Remove `CheckHardwareHash` and `UpdateHardware` from `IUserService` / `UserService` (`Azaion.Services/UserService.cs`).
|
||||||
|
- Remove `PUT /users/hardware/set` endpoint from `Azaion.AdminApi/Program.cs`.
|
||||||
|
- Simplify `POST /resources/get/{dataFolder}` (`Program.cs` + `Azaion.Services/ResourcesService.cs`): remove `request.Hardware` parameter usage; derive encryption key without the hardware hash.
|
||||||
|
- Simplify `POST /resources/check`: remove the hardware-binding side-effect entirely. If the endpoint becomes purely a "do I have any newer resources?" probe, keep it; if it becomes a no-op shell, remove it (decide based on what consumers still call today).
|
||||||
|
- Update `Security.GetApiEncryptionKey` (`Azaion.Services/Security.cs`) to drop the `hardwareHash` parameter from its signature; derive the key from `email + password` only.
|
||||||
|
- Remove (do not deprecate) `Security.GetHWHash` — the codebase has no Loader to coordinate with anymore.
|
||||||
|
- Remove `SetHWRequest` DTO (`Azaion.Common/Requests/SetHWRequest.cs`).
|
||||||
|
- Remove the `Hardware` property usage from `GetResourceRequest` (`Azaion.Common/Requests/GetResourceRequest.cs`). The wire field may still be **accepted** (deserialized and ignored) for one release cycle to keep any in-flight legacy clients from breaking on 400s — pick the simplest of (drop entirely / accept-and-ignore) and document the choice in the implementation report.
|
||||||
|
- Remove `HardwareIdMismatch` and `BadHardware` from `Azaion.Common/BusinessException.cs` `ExceptionEnum`.
|
||||||
|
- Leave `User.Hardware` column in DB (nullable, unused). No migration here.
|
||||||
|
|
||||||
|
### Included — Tests in this workspace
|
||||||
|
|
||||||
|
- Delete `e2e/Azaion.E2E/Tests/HardwareBindingTests.cs` entirely (every test in that file asserts behaviour that is being removed).
|
||||||
|
- Update `e2e/Azaion.E2E/Tests/ResourceTests.cs`, `ResilienceTests.cs`, `SecurityTests.cs` to stop sending the `Hardware` field on resource calls (or to assert the field is ignored, whichever matches the chosen wire-compat policy above).
|
||||||
|
- Update `Azaion.Test/UserServiceTest.cs` and `Azaion.Test/SecurityTest.cs` to remove tests asserting hardware-hash behaviour and to drop the `hardwareHash` argument from any retained `GetApiEncryptionKey` calls.
|
||||||
|
- Trim test fixtures in `db-init/` and `Azaion.Test` if they seed a `User.Hardware` value purely to satisfy hardware-binding flows.
|
||||||
|
|
||||||
|
### Included — Workspace docs (pointer-only updates, no full rewrite)
|
||||||
|
|
||||||
|
- Mark `_docs/02_document/diagrams/flows/flow_hardware_check.md` as obsolete (header note + link to AZ-197 implementation report) — full deletion is fine if cleaner.
|
||||||
|
- Mark `_docs/02_document/modules/common_requests_set_hw.md` as obsolete (the documented module no longer exists).
|
||||||
|
- Note in `_docs/02_document/architecture.md` (Security & Encryption section) that API encryption-key derivation no longer includes a hardware hash, and that `User.Hardware` is a tombstoned column.
|
||||||
|
|
||||||
|
### Excluded
|
||||||
|
|
||||||
|
- Database migration to drop the `hardware` column from `users` (separate ticket if/when desired; harmless to leave nullable).
|
||||||
|
- Changes to user registration or login flow (those don't touch the hardware path).
|
||||||
|
- Any change to the suite-level `_docs/00_top_level_architecture.md` "Security & Encryption" / "Binary Split Security" sections — that's part of `unresolved:loader-retirement-arch-doc` and is owned at the suite level, not by this ticket.
|
||||||
|
- Live-device decommissioning of fielded Loader containers (separate ops runway, tracked as a sibling of `loader-retirement-arch-doc`).
|
||||||
|
- Anything in the `loader/` workspace — it does not exist in the suite anymore.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
**AC-1: Resource download works without hardware**
|
||||||
|
Given a provisioned device user with valid email and password
|
||||||
|
When `POST /resources/get/{dataFolder}` is called without a `Hardware` field
|
||||||
|
Then the resource is returned and decrypts successfully using a key derived from email + password only
|
||||||
|
|
||||||
|
**AC-2: Hardware-set endpoint is gone**
|
||||||
|
Given the updated admin API
|
||||||
|
When `PUT /users/hardware/set` is called with any payload
|
||||||
|
Then the response is 404
|
||||||
|
|
||||||
|
**AC-3: Encryption-key derivation is simplified**
|
||||||
|
Given the updated `Security.GetApiEncryptionKey`
|
||||||
|
When it is called with `(email, password)`
|
||||||
|
Then it returns the key derived from `email + password` only — there is no `hardwareHash` parameter on the public signature
|
||||||
|
|
||||||
|
**AC-4: Hardware-binding tests are gone**
|
||||||
|
Given the updated test projects
|
||||||
|
When the test suite is built and listed
|
||||||
|
Then `HardwareBindingTests` does not exist and no remaining test asserts `HardwareIdMismatch` / error code 40 / hardware-hash binding
|
||||||
|
|
||||||
|
**AC-5: Resource calls in remaining tests do not send `Hardware`**
|
||||||
|
Given the updated `ResourceTests`, `ResilienceTests`, `SecurityTests`
|
||||||
|
When the resource-download / resource-check requests are inspected
|
||||||
|
Then no test sends a `Hardware` field on any resource request (or, if accept-and-ignore wire-compat was chosen, tests assert the response is unchanged whether `Hardware` is present or absent)
|
||||||
|
|
||||||
|
**AC-6: ExceptionEnum no longer has hardware codes**
|
||||||
|
Given `Azaion.Common/BusinessException.cs`
|
||||||
|
When the `ExceptionEnum` is read
|
||||||
|
Then `HardwareIdMismatch` and `BadHardware` entries are gone, and no production code references them
|
||||||
|
|
||||||
|
**AC-7: Build is clean**
|
||||||
|
Given the workspace after the changes
|
||||||
|
When `dotnet build` runs across the solution
|
||||||
|
Then it completes with no errors and no new warnings introduced by this ticket
|
||||||
|
|
||||||
|
**AC-8: Test suite passes**
|
||||||
|
Given the workspace after the changes
|
||||||
|
When the existing test suite (`Azaion.Test` + `e2e/Azaion.E2E`) is run via `docker-compose.test.yml`
|
||||||
|
Then all tests pass (the deleted `HardwareBindingTests` are not counted)
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Wire-compat policy on the `Hardware` field of resource requests must be chosen explicitly (drop / accept-and-ignore) and recorded in the implementation report — this is the only consumer-facing contract change in the ticket.
|
||||||
|
- Do not rename `User.Hardware` column or drop it from the entity in this ticket; only stop reading/writing it. Renaming/dropping requires a separate migration ticket per the workspace's "no rename without confirmation" rule.
|
||||||
|
|
||||||
|
## Cross-architecture context
|
||||||
|
|
||||||
|
This ticket is the admin-side half of an architectural transition that has already happened:
|
||||||
|
|
||||||
|
- Loader retirement (Scenario X) — `suite/_docs/_repo-config.yaml` → `unresolved:loader-retirement-arch-doc`
|
||||||
|
- Suite-root restructure (2026-04-19) — see assumptions_log entries in the same file
|
||||||
|
- Admin-side hardware-binding cleanup — **this ticket** (AZ-197)
|
||||||
|
|
||||||
|
The matching suite-doc refresh (top-level architecture, Binary Split Security section) is tracked separately under the unresolved item above and is intentionally NOT in this ticket's scope.
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Remove Hardware ID Binding
|
|
||||||
|
|
||||||
**Task**: AZ-197_remove_hardware_id
|
|
||||||
**Name**: Remove hardware ID binding from resource flow
|
|
||||||
**Description**: Remove CheckHardwareHash, UpdateHardware, HardwareService and simplify API encryption key derivation. Sealed Jetsons eliminate the credential-reuse threat this was protecting against.
|
|
||||||
**Complexity**: 3 points
|
|
||||||
**Dependencies**: None
|
|
||||||
**Component**: Admin API, Loader
|
|
||||||
**Tracker**: AZ-197
|
|
||||||
**Epic**: AZ-181
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The `Hardware` field on `User` and the `CheckHardwareHash` flow were designed to bind a user account to a specific physical machine, preventing credential reuse across machines when users had desktop installers. With sealed Jetsons (secure boot, fTPM, no user filesystem access, no installers distributed), this threat no longer exists. The hardware binding adds unnecessary complexity and failure modes (HardwareIdMismatch on drive replacement, etc.).
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
|
|
||||||
- Simpler resource download flow without hardware fingerprint requirement
|
|
||||||
- Simpler API encryption key derivation (email + password only)
|
|
||||||
- Removal of dead code paths related to hardware binding
|
|
||||||
- Fewer failure modes in production
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### Admin API changes
|
|
||||||
|
|
||||||
- Remove `CheckHardwareHash` and `UpdateHardware` from `IUserService` / `UserService`
|
|
||||||
- Remove `PUT /users/hardware/set` endpoint from `Program.cs`
|
|
||||||
- Simplify `POST /resources/get/{dataFolder}`: remove `request.Hardware` parameter, derive encryption key without hardware hash
|
|
||||||
- Simplify `POST /resources/check`: remove hardware check entirely (or remove the endpoint if unused)
|
|
||||||
- Update `Security.GetApiEncryptionKey` to not require `hardwareHash` parameter
|
|
||||||
- Remove or deprecate `Security.GetHWHash`
|
|
||||||
- Leave `User.Hardware` column nullable in DB (no migration needed, just stop writing/reading it)
|
|
||||||
- Remove `SetHWRequest` DTO
|
|
||||||
- Remove `HardwareIdMismatch` and `BadHardware` from `ExceptionEnum`
|
|
||||||
|
|
||||||
### Loader client changes
|
|
||||||
|
|
||||||
- Remove `HardwareService` class (`hardware_service.pyx`, `hardware_service.pxd`)
|
|
||||||
- Update `api_client.pyx` `load_bytes`: stop gathering hardware info, stop sending `hardware` field in resource request
|
|
||||||
- Update `security.pyx` `get_api_encryption_key`: remove `hardware_hash` parameter
|
|
||||||
- Update `security_provider.py`, `tpm_security_provider.py`, `legacy_security_provider.py`: remove `get_hw_hash` and update `get_api_encryption_key` signature
|
|
||||||
- Update `GetResourceRequest` validator to not require Hardware field
|
|
||||||
|
|
||||||
### Excluded
|
|
||||||
|
|
||||||
- Database migration to drop the `hardware` column (leave nullable, stop using it)
|
|
||||||
- Changes to user registration or login flow
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
**AC-1: Resource download works without hardware**
|
|
||||||
Given a provisioned device with valid email and password
|
|
||||||
When the loader calls POST /resources/get without a hardware field
|
|
||||||
Then the resource is returned and can be decrypted using email + password only
|
|
||||||
|
|
||||||
**AC-2: No hardware endpoints remain**
|
|
||||||
Given the updated admin API
|
|
||||||
When PUT /users/hardware/set is called
|
|
||||||
Then 404 is returned
|
|
||||||
|
|
||||||
**AC-3: Encryption key derivation is simplified**
|
|
||||||
Given the updated Security class
|
|
||||||
When GetApiEncryptionKey is called
|
|
||||||
Then it derives the key from email + password only (no hardware hash)
|
|
||||||
|
|
||||||
**AC-4: HardwareService removed from loader**
|
|
||||||
Given the updated loader codebase
|
|
||||||
When the build is run
|
|
||||||
Then it compiles without hardware_service.pyx/pxd
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Batch 06 Implementation Report
|
||||||
|
|
||||||
|
**Date**: 2026-05-13
|
||||||
|
**Cycle**: 1, batch 2 of 2
|
||||||
|
**Tasks**: AZ-197 (remove hardware ID binding from resource flow — admin-side cleanup)
|
||||||
|
**Total complexity**: 3 points
|
||||||
|
**Implementer**: implement skill (autodev cycle 1)
|
||||||
|
|
||||||
|
## Why this batch is one-task
|
||||||
|
|
||||||
|
Batch 5 was additive (AZ-513, AZ-196, AZ-183 — three new endpoints + a new table). Batch 6 is the destructive complement: it removes a cross-cutting feature (hardware fingerprint binding) that touches services, DTOs, business-exception codes, route table, and tests across two projects.
|
||||||
|
|
||||||
|
Splitting destructive cleanup from new-feature work was a deliberate batching decision (recorded in `batch_05_report.md` §"AZ-197 not in this batch"). It makes the diff focused and reviewable, and it keeps the risk envelope of the additive batch independent.
|
||||||
|
|
||||||
|
## Wire-compat policy decision (recorded per task-spec ¶6 of "Included")
|
||||||
|
|
||||||
|
The AZ-197 spec offers two options for the `Hardware` field on resource requests: **drop entirely** vs. **accept-and-ignore**.
|
||||||
|
|
||||||
|
**Decision: drop entirely.**
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- The Loader workspace is architecturally retired (`suite/_docs/_repo-config.yaml` `unresolved:loader-retirement-arch-doc`). There is no in-flight legacy Loader client that needs a forgiving wire format.
|
||||||
|
- New edge devices ship as fTPM-secured Jetsons running freshly-deployed software; new SaaS clients send what we tell them to send.
|
||||||
|
- "Accept-and-ignore" would require keeping the field on `GetResourceRequest`, the validator, and the Loader-shaped DTO docs — purely as a tombstone. The maintenance cost is non-zero and the benefit is zero.
|
||||||
|
- A single 400 on a stale client (if any exists) is preferable to silently accepting a hardware fingerprint that the server now ignores. A loud, fast failure is the right behaviour.
|
||||||
|
|
||||||
|
This decision is also reflected in the e2e `SecurityTests.Hardware_endpoints_are_removed_AZ_197` regression test, which asserts the routes return 404 (not 200-with-warning).
|
||||||
|
|
||||||
|
## Implementation summary by file
|
||||||
|
|
||||||
|
### Production code
|
||||||
|
|
||||||
|
- **`Azaion.AdminApi/Program.cs`** — removed `MapPut("/users/hardware/set", ...)` (4 lines including `.RequireAuthorization`/`.WithSummary`). Removed `MapPost("/resources/check", ...)` (8 lines). Simplified `MapPost("/resources/get/{dataFolder?}", ...)`: dropped the `IUserService` parameter and the `await userService.CheckHardwareHash(...)` call; `Security.GetApiEncryptionKey` now invoked with `(user.Email, request.Password)`.
|
||||||
|
|
||||||
|
- **`Azaion.Services/UserService.cs`** — removed `UpdateHardware` and `CheckHardwareHash` methods + their `IUserService` declarations. Removed the private `UpdateLastLoginDate(User user)` helper (sole caller was `CheckHardwareHash`). The interface now declares 9 methods (was 11).
|
||||||
|
|
||||||
|
- **`Azaion.Services/Security.cs`** — `GetApiEncryptionKey` signature changed from `(string email, string password, string? hardwareHash)` to `(string email, string password)`. Implementation removes the `-{hardwareHash}` segment from the hashed input. `GetHWHash` deleted entirely.
|
||||||
|
|
||||||
|
- **`Azaion.Common/Requests/GetResourceRequest.cs`** — removed the `Hardware` string property. The companion `GetResourceRequestValidator` (same file) lost its `RuleFor(x => x.Hardware)` rule.
|
||||||
|
|
||||||
|
- **`Azaion.Common/Requests/SetHWRequest.cs`** — file deleted.
|
||||||
|
|
||||||
|
- **`Azaion.Common/BusinessException.cs`** — removed two `ExceptionEnum` entries: `HardwareIdMismatch = 40` and `BadHardware = 45`. The numeric codes are intentionally left as a gap (40 and 45 are now reserved-by-history, not to be reused).
|
||||||
|
|
||||||
|
### Test code
|
||||||
|
|
||||||
|
- **`Azaion.Test/SecurityTest.cs`** — dropped the third positional `hardwareId` argument from two `GetApiEncryptionKey` invocations.
|
||||||
|
|
||||||
|
- **`Azaion.Test/UserServiceTest.cs`** — file deleted (sole test asserted the removed `CheckHardwareHash`).
|
||||||
|
|
||||||
|
- **`e2e/Azaion.E2E/Tests/HardwareBindingTests.cs`** — file deleted (165 lines). Every scenario asserted behaviour AZ-197 removes.
|
||||||
|
|
||||||
|
- **`e2e/Azaion.E2E/Tests/ResourceTests.cs`** — purged hardware references: removed `SampleHardware` constant, removed `/resources/check` precondition calls, dropped `Hardware = ...` from `/resources/get` payloads, simplified `DecryptResourcePayload` helper (no `hardware` parameter; key derivation matches the new `Security.GetApiEncryptionKey`).
|
||||||
|
|
||||||
|
- **`e2e/Azaion.E2E/Tests/SecurityTests.cs`** — dropped `hardware` from anonymous payload in `Unauthenticated_requests_to_protected_endpoints_return_401`. Refactored `DownloadForAsync` helper inside `Per_user_encryption_produces_distinct_ciphertext_for_same_file` to drop the `hardware` parameter and the `/resources/check` step. **Added** `Hardware_endpoints_are_removed_AZ_197` — a regression test asserting both `PUT /users/hardware/set` and `POST /resources/check` return 404.
|
||||||
|
|
||||||
|
- **`e2e/Azaion.E2E/Tests/ResilienceTests.cs`** — deleted `Concurrent_hardware_binding_same_hardware_has_no_500_and_state_consistent`. Adjacent hygiene: dropped now-unused `System.Net.Http.Json` and `System.Text.Json` `using` directives, plus the orphaned `ResponseJsonOptions` field and `TestUserPassword` constant.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **`_docs/02_document/diagrams/flows/flow_hardware_check.md`** — converted to a tombstone (header note + minimal "REMOVED" Mermaid). Retained so historical cross-references resolve.
|
||||||
|
|
||||||
|
- **`_docs/02_document/architecture.md`** — five edits:
|
||||||
|
1. Top-level System Context note explaining the AZ-197 cleanup.
|
||||||
|
2. Integration table: dropped "hardware check" from the Azaion Suite client row.
|
||||||
|
3. Data-model entity table: marked `User.Hardware` as a tombstoned column.
|
||||||
|
4. ADR-003 (Per-User Resource Encryption) updated with the new key formula and an explicit cross-reference to ADR-004.
|
||||||
|
5. ADR-004 (Hardware Fingerprint Binding) renamed to "RETIRED (AZ-197)" with full retirement rationale (sealed Jetson + SaaS architecture, fTPM key storage).
|
||||||
|
|
||||||
|
## Notes / known consequences (non-blocking)
|
||||||
|
|
||||||
|
1. **`User.LastLogin` is now never written by the application.** Pre-AZ-197, its only writer was `UpdateLastLoginDate`, called inside `CheckHardwareHash`. The login path (`ValidateUser`) never wrote it. The field is therefore now effectively dead. **Out of scope for AZ-197**; flagged in batch 6 review for a future ticket.
|
||||||
|
|
||||||
|
2. **`User.Hardware` column is a nullable tombstone.** Spec explicitly forbids the migration in this ticket (workspace rule "no rename without confirmation" + "avoid renaming if possible").
|
||||||
|
|
||||||
|
3. **Numeric error-code gap.** Codes 40 (`HardwareIdMismatch`) and 45 (`BadHardware`) are now unused. Intentional — never reuse retired error codes. Worth a `coderule.mdc` line if not already covered (out of scope here).
|
||||||
|
|
||||||
|
## Build status
|
||||||
|
|
||||||
|
| Target | Result |
|
||||||
|
|---|---|
|
||||||
|
| `dotnet build Azaion.AdminApi.sln` | 0 errors, 0 warnings |
|
||||||
|
| `dotnet build e2e/Azaion.E2E/Azaion.E2E.csproj` | 0 errors, 0 warnings |
|
||||||
|
|
||||||
|
## Test status
|
||||||
|
|
||||||
|
Full `dotnet test` runs in **step 16** under the test-run skill. Build is green; expectation is a full pass with one fewer test class (`HardwareBindingTests`) and one fewer test in `ResilienceTests`, plus the new `SecurityTests.Hardware_endpoints_are_removed_AZ_197` test.
|
||||||
|
|
||||||
|
## Tracker linkage
|
||||||
|
|
||||||
|
- AZ-197 will transition `In Progress → In Testing` after the batch-6 commit lands; final transition to `Done` after the test-run step confirms the full suite passes.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Batch 06 Code Review
|
||||||
|
|
||||||
|
**Date**: 2026-05-13
|
||||||
|
**Cycle**: 1, batch 2 of 2
|
||||||
|
**Tasks reviewed**: AZ-197 (remove hardware ID binding from resource flow — admin-side cleanup)
|
||||||
|
**Reviewer**: implement skill (Phase 9 — code-review)
|
||||||
|
**Verdict**: **PASS**
|
||||||
|
|
||||||
|
## Phase 1 — Production code touched
|
||||||
|
|
||||||
|
| File | Change | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Azaion.AdminApi/Program.cs` | Removed `MapPut("/users/hardware/set")`. Removed `MapPost("/resources/check")`. Simplified `MapPost("/resources/get/{dataFolder?}")` to derive key from `email + password` only. | Endpoints disappear cleanly; remaining route table unchanged. |
|
||||||
|
| `Azaion.Services/UserService.cs` | Removed `UpdateHardware`, `CheckHardwareHash`, `UpdateLastLoginDate` (private helper only used by `CheckHardwareHash`). Removed both methods from `IUserService`. | `UpdateLastLoginDate` was the *only* writer of `User.LastLogin`; consequence noted in §Implementation notes. |
|
||||||
|
| `Azaion.Services/Security.cs` | Removed `GetHWHash`. Changed `GetApiEncryptionKey(email, password, hardwareHash)` → `GetApiEncryptionKey(email, password)`. | Public-API breaking change inside the workspace; all callers updated. |
|
||||||
|
| `Azaion.Common/Requests/GetResourceRequest.cs` | Removed `Hardware` property and its FluentValidation rule. | Wire-compat policy: **drop entirely** (see Implementation notes for rationale). |
|
||||||
|
| `Azaion.Common/Requests/SetHWRequest.cs` | Deleted. | The DTO had a single consumer (the deleted endpoint). |
|
||||||
|
| `Azaion.Common/BusinessException.cs` | Removed `HardwareIdMismatch = 40` and `BadHardware = 45` from `ExceptionEnum`. | Numeric codes 40 and 45 are now unused — left as a numeric gap. **Do not reuse**. |
|
||||||
|
|
||||||
|
## Phase 2 — Test code touched
|
||||||
|
|
||||||
|
| File | Change | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Azaion.Test/SecurityTest.cs` | Dropped third `hardwareId` argument from two `GetApiEncryptionKey` calls. | Compile-only fix — assertions unchanged. |
|
||||||
|
| `Azaion.Test/UserServiceTest.cs` | Deleted. | Sole test was `CheckHardwareHashTest`; method under test no longer exists. |
|
||||||
|
| `e2e/Azaion.E2E/Tests/HardwareBindingTests.cs` | Deleted (165 lines). | All scenarios asserted behaviour that AZ-197 removes. |
|
||||||
|
| `e2e/Azaion.E2E/Tests/ResourceTests.cs` | Removed `SampleHardware` constant. Dropped `/resources/check` calls from two scenarios. Dropped `Hardware` field from `/resources/get` payloads. Updated `DecryptResourcePayload` helper signature + body. | Encryption round-trip now uses 2-arg key formula. |
|
||||||
|
| `e2e/Azaion.E2E/Tests/SecurityTests.cs` | Dropped `hardware` from anonymous `/resources/get` payload (unauth probe). Refactored `Per_user_encryption_produces_distinct_ciphertext_for_same_file` `DownloadForAsync` helper to drop `hardware` parameter and the `/resources/check` precondition. **Added** `Hardware_endpoints_are_removed_AZ_197` (AC-2 regression test asserting both removed routes return 404). | New test is the only added test surface in this batch. |
|
||||||
|
| `e2e/Azaion.E2E/Tests/ResilienceTests.cs` | Deleted `Concurrent_hardware_binding_same_hardware_has_no_500_and_state_consistent`. Removed now-unused `using System.Net.Http.Json;`, `using System.Text.Json;`, `ResponseJsonOptions` field, `TestUserPassword` constant. | The scenario has no analogue post-AZ-197. |
|
||||||
|
|
||||||
|
## Phase 3 — Documentation touched
|
||||||
|
|
||||||
|
| File | Change | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `_docs/02_document/diagrams/flows/flow_hardware_check.md` | Replaced with a tombstone marker pointing to the AZ-197 spec. | Retained so historical links resolve. Mermaid diagram reduced to a single "REMOVED" node. |
|
||||||
|
| `_docs/02_document/architecture.md` | Updated System Context, integration table, data-model table, ADR-003 (per-user encryption — new key formula), ADR-004 (retired with full rationale). Added a top-level note about the AZ-197 cleanup. | Single source of truth for the new architecture. |
|
||||||
|
|
||||||
|
## Phase 4 — Build status
|
||||||
|
|
||||||
|
- `dotnet build Azaion.AdminApi.sln` — **0 errors, 0 warnings**.
|
||||||
|
- `dotnet build e2e/Azaion.E2E/Azaion.E2E.csproj` — **0 errors, 0 warnings**.
|
||||||
|
|
||||||
|
## Phase 5 — AC coverage check
|
||||||
|
|
||||||
|
| AC | Verification |
|
||||||
|
|---|---|
|
||||||
|
| AC-1 — Resource download works without hardware | `ResourceTests.Encrypted_download_returns_octet_stream_and_non_empty_body`, `Encryption_round_trip_decrypt_matches_original_bytes`, `SecurityTests.Per_user_encryption_produces_distinct_ciphertext_for_same_file` (all send no `Hardware` field). |
|
||||||
|
| AC-2 — `PUT /users/hardware/set` is gone | New `SecurityTests.Hardware_endpoints_are_removed_AZ_197` asserts 404 on both `PUT /users/hardware/set` and `POST /resources/check`. |
|
||||||
|
| AC-3 — `Security.GetApiEncryptionKey` signature | Compile-time enforced; `Azaion.Test/SecurityTest.cs` exercises the 2-arg overload. |
|
||||||
|
| AC-4 — `HardwareBindingTests` removed | File deleted (`git status` shows `D e2e/Azaion.E2E/Tests/HardwareBindingTests.cs`). |
|
||||||
|
| AC-5 — No remaining test sends `Hardware` | `rg` over `Azaion.Test` and `e2e/Azaion.E2E` returns no `Hardware =` / `hardware =` payloads outside the AC-2 negative-path test. |
|
||||||
|
| AC-6 — `ExceptionEnum` no longer has hardware codes | Confirmed by `BusinessException.cs` read; codes 40 and 45 are absent. Compile would have failed if any production reference remained. |
|
||||||
|
| AC-7 — All tests pass | Deferred to step 16 (test-run skill). Build is green; expectation is full pass. |
|
||||||
|
|
||||||
|
## Phase 6 — Scope discipline
|
||||||
|
|
||||||
|
- All changes are within the AZ-197 scope as defined by the task spec (Admin API production code + workspace tests + listed docs).
|
||||||
|
- `User.Hardware` entity property and DB column **left in place** per the spec ("DB column tombstoned, no migration in this ticket").
|
||||||
|
- Adjacent hygiene performed: removed now-unused `using` directives in `ResilienceTests.cs`; removed dead private helper `UpdateLastLoginDate`. Both were caused by the in-scope deletions.
|
||||||
|
- No unrelated fixes piggy-backed.
|
||||||
|
|
||||||
|
## Phase 7 — Risks / follow-ups (non-blocking)
|
||||||
|
|
||||||
|
1. **`User.LastLogin` is now never updated by application code.** Pre-AZ-197, the only writer was `UpdateLastLoginDate`, called from `CheckHardwareHash`. `ValidateUser` (login) does not update it. The column becomes write-once via direct DB inserts. This was *also* true post-AZ-197 in the sense that the only updater fired during a hardware-check (not during login), so this is not a *regression* in the login path — but it does mean the field is now effectively dead. Two options for a future ticket:
|
||||||
|
- Move a `LastLogin = UtcNow` write into `ValidateUser` (preserves the spirit of the field).
|
||||||
|
- Drop the column.
|
||||||
|
Out of scope for AZ-197; documented for future cleanup.
|
||||||
|
2. **`User.Hardware` column tombstone.** Per spec — no migration in this ticket. Future cleanup ticket can drop the column once any external readers (BI dashboards, audit exports) are confirmed absent.
|
||||||
|
3. **Numeric error code gap (40, 45).** Intentional. A code-review rule "do not reuse retired error codes" would be worth adding to `coderule.mdc` if it isn't already there — out of scope here.
|
||||||
|
|
||||||
|
## Verdict — PASS
|
||||||
|
|
||||||
|
No blocking issues. Build is clean across both projects. AC coverage is complete (AC-7 confirmed at step 16 by `dotnet test`). Tombstone strategy for `User.Hardware` and `User.LastLogin` is documented and explicitly out of scope per the task spec. Ready for commit.
|
||||||
@@ -6,9 +6,9 @@ step: 10
|
|||||||
name: Implement
|
name: Implement
|
||||||
status: in_progress
|
status: in_progress
|
||||||
sub_step:
|
sub_step:
|
||||||
phase: 6
|
phase: 11
|
||||||
name: implement-tasks-sequentially
|
name: commit
|
||||||
detail: "batch 1 / step 9 — code-review"
|
detail: "batch 2 of 2 (AZ-197) — code reviewed PASS, ready to commit"
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 1
|
cycle: 1
|
||||||
tracker: jira
|
tracker: jira
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Azaion.E2E.Helpers;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Azaion.E2E.Tests;
|
|
||||||
|
|
||||||
[Collection("E2E")]
|
|
||||||
public sealed class HardwareBindingTests
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
private const string TestUserPassword = "TestPass1234";
|
|
||||||
private const string SampleHardware =
|
|
||||||
"CPU: TestCPU. GPU: TestGPU. Memory: 16384. DriveSerial: TESTDRIVE001.";
|
|
||||||
|
|
||||||
private sealed record ErrorResponse(int ErrorCode, string Message);
|
|
||||||
|
|
||||||
private readonly TestFixture _fixture;
|
|
||||||
|
|
||||||
public HardwareBindingTests(TestFixture fixture) => _fixture = fixture;
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task First_hardware_check_binds_and_returns_200_true()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
string? email = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var candidateEmail = $"hwtest-{Guid.NewGuid()}@azaion.com";
|
|
||||||
using (var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
|
|
||||||
{
|
|
||||||
using var createResp = await adminClient.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/check", new { Hardware = SampleHardware });
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
||||||
var body = await response.Content.ReadFromJsonAsync<bool>(ResponseJsonOptions);
|
|
||||||
body.Should().BeTrue();
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Repeat_hardware_check_with_same_hardware_returns_200_true()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
string? email = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var candidateEmail = $"hwtest-{Guid.NewGuid()}@azaion.com";
|
|
||||||
using (var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
|
|
||||||
{
|
|
||||||
using var createResp = await adminClient.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);
|
|
||||||
using (var first = await userClient.PostAsync("/resources/check", new { Hardware = SampleHardware }))
|
|
||||||
{
|
|
||||||
first.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Act
|
|
||||||
using var response = await userClient.PostAsync("/resources/check", new { Hardware = SampleHardware });
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
||||||
var body = await response.Content.ReadFromJsonAsync<bool>(ResponseJsonOptions);
|
|
||||||
body.Should().BeTrue();
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Hardware_mismatch_returns_409_with_error_code_40()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
const string hardwareA = "HARDWARE_A";
|
|
||||||
const string hardwareB = "HARDWARE_B";
|
|
||||||
string? email = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var candidateEmail = $"hwtest-{Guid.NewGuid()}@azaion.com";
|
|
||||||
using (var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
|
|
||||||
{
|
|
||||||
using var createResp = await adminClient.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);
|
|
||||||
using (var bind = await userClient.PostAsync("/resources/check", new { Hardware = hardwareA }))
|
|
||||||
{
|
|
||||||
bind.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Act
|
|
||||||
using var response = await userClient.PostAsync("/resources/check", new { Hardware = hardwareB });
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
|
||||||
var err = await response.Content.ReadFromJsonAsync<ErrorResponse>(ResponseJsonOptions);
|
|
||||||
err.Should().NotBeNull();
|
|
||||||
err!.ErrorCode.Should().Be(40);
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Azaion.E2E.Helpers;
|
using Azaion.E2E.Helpers;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -11,12 +9,6 @@ namespace Azaion.E2E.Tests;
|
|||||||
[Collection("E2E")]
|
[Collection("E2E")]
|
||||||
public sealed class ResilienceTests
|
public sealed class ResilienceTests
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
private const string TestUserPassword = "TestPass1234";
|
|
||||||
private const string MalformedJwtUnsigned =
|
private const string MalformedJwtUnsigned =
|
||||||
"eyJhbGciOiJub25lIn0.eyJ0ZXN0IjoiMSJ9.";
|
"eyJhbGciOiJub25lIn0.eyJ0ZXN0IjoiMSJ9.";
|
||||||
|
|
||||||
@@ -60,64 +52,6 @@ public sealed class ResilienceTests
|
|||||||
token.Should().NotBeNullOrWhiteSpace();
|
token.Should().NotBeNullOrWhiteSpace();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Concurrent_hardware_binding_same_hardware_has_no_500_and_state_consistent()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
string? email = null;
|
|
||||||
var hardware =
|
|
||||||
$"CPU: ConcCPU. GPU: ConcGPU. Memory: 8192. DriveSerial: {Guid.NewGuid():N}.";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var candidateEmail = $"resilience-hw-{Guid.NewGuid()}@azaion.com";
|
|
||||||
using (var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken))
|
|
||||||
{
|
|
||||||
using var createResp = await adminClient.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
|
|
||||||
var concurrentTasks = Enumerable.Range(0, 5)
|
|
||||||
.Select(_ => userClient.PostAsync("/resources/check", new { Hardware = hardware }))
|
|
||||||
.ToArray();
|
|
||||||
var concurrentResponses = await Task.WhenAll(concurrentTasks);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
foreach (var r in concurrentResponses)
|
|
||||||
{
|
|
||||||
using (r)
|
|
||||||
{
|
|
||||||
r.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Act
|
|
||||||
using var followUp = await userClient.PostAsync("/resources/check", new { Hardware = hardware });
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
followUp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
||||||
var body = await followUp.Content.ReadFromJsonAsync<bool>(ResponseJsonOptions);
|
|
||||||
body.Should().BeTrue();
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Login_p95_latency_under_500ms_after_warmup()
|
public async Task Login_p95_latency_under_500ms_after_warmup()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ public sealed class ResourceTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
private const string TestUserPassword = "TestPass1234";
|
private const string TestUserPassword = "TestPass1234";
|
||||||
private const string SampleHardware =
|
|
||||||
"CPU: TestCPU. GPU: TestGPU. Memory: 16384. DriveSerial: TESTDRIVE001.";
|
|
||||||
|
|
||||||
private sealed record ErrorResponse(int ErrorCode, string Message);
|
private sealed record ErrorResponse(int ErrorCode, string Message);
|
||||||
|
|
||||||
@@ -82,14 +80,10 @@ public sealed class ResourceTests
|
|||||||
using var loginClient = _fixture.CreateApiClient();
|
using var loginClient = _fixture.CreateApiClient();
|
||||||
var userToken = await loginClient.LoginAsync(email, TestUserPassword);
|
var userToken = await loginClient.LoginAsync(email, TestUserPassword);
|
||||||
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
|
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
|
||||||
using (var bind = await userClient.PostAsync("/resources/check", new { Hardware = SampleHardware }))
|
|
||||||
{
|
|
||||||
bind.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var response = await userClient.PostAsync($"/resources/get/{folder}",
|
using var response = await userClient.PostAsync($"/resources/get/{folder}",
|
||||||
new { Password = TestUserPassword, Hardware = SampleHardware, FileName = fileName });
|
new { Password = TestUserPassword, FileName = fileName });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
@@ -120,7 +114,6 @@ public sealed class ResourceTests
|
|||||||
const string fileName = "roundtrip.bin";
|
const string fileName = "roundtrip.bin";
|
||||||
var original = Enumerable.Range(0, 128).Select(i => (byte)i).ToArray();
|
var original = Enumerable.Range(0, 128).Select(i => (byte)i).ToArray();
|
||||||
const string password = "RoundTrip123";
|
const string password = "RoundTrip123";
|
||||||
const string hardware = "RT-HW-CPU-001-GPU-002";
|
|
||||||
string? email = null;
|
string? email = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -144,17 +137,13 @@ public sealed class ResourceTests
|
|||||||
using var loginClient = _fixture.CreateApiClient();
|
using var loginClient = _fixture.CreateApiClient();
|
||||||
var userToken = await loginClient.LoginAsync(email, password);
|
var userToken = await loginClient.LoginAsync(email, password);
|
||||||
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
|
using var userClient = _fixture.CreateAuthenticatedClient(userToken);
|
||||||
using (var bind = await userClient.PostAsync("/resources/check", new { Hardware = hardware }))
|
|
||||||
{
|
|
||||||
bind.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var download = await userClient.PostAsync($"/resources/get/{folder}",
|
using var download = await userClient.PostAsync($"/resources/get/{folder}",
|
||||||
new { Password = password, Hardware = hardware, FileName = fileName });
|
new { Password = password, FileName = fileName });
|
||||||
download.EnsureSuccessStatusCode();
|
download.EnsureSuccessStatusCode();
|
||||||
var encrypted = await download.Content.ReadAsByteArrayAsync();
|
var encrypted = await download.Content.ReadAsByteArrayAsync();
|
||||||
var decrypted = DecryptResourcePayload(encrypted, email!, password, hardware);
|
var decrypted = DecryptResourcePayload(encrypted, email!, password);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
decrypted.Should().Equal(original);
|
decrypted.Should().Equal(original);
|
||||||
@@ -194,12 +183,10 @@ public sealed class ResourceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] DecryptResourcePayload(byte[] encrypted, string email, string password, string hardware)
|
private static byte[] DecryptResourcePayload(byte[] encrypted, string email, string password)
|
||||||
{
|
{
|
||||||
var hwHash = Convert.ToBase64String(SHA384.HashData(
|
|
||||||
Encoding.UTF8.GetBytes($"Azaion_{hardware}_%$$$)0_")));
|
|
||||||
var apiKey = Convert.ToBase64String(SHA384.HashData(
|
var apiKey = Convert.ToBase64String(SHA384.HashData(
|
||||||
Encoding.UTF8.GetBytes($"{email}-{password}-{hwHash}-#%@AzaionKey@%#---")));
|
Encoding.UTF8.GetBytes($"{email}-{password}-#%@AzaionKey@%#---")));
|
||||||
var aesKey = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey));
|
var aesKey = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey));
|
||||||
|
|
||||||
if (encrypted.Length <= 16)
|
if (encrypted.Length <= 16)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public sealed class SecurityTests
|
|||||||
r.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
r.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
|
||||||
using (var r = await client.PostAsync("/resources/get",
|
using (var r = await client.PostAsync("/resources/get",
|
||||||
new { password = "irrelevant1", hardware = "h", fileName = "f.bin" }))
|
new { password = "irrelevant1", fileName = "f.bin" }))
|
||||||
r.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
r.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,8 +151,6 @@ public sealed class SecurityTests
|
|||||||
var email1 = $"{Guid.NewGuid():N}@sectest.example.com";
|
var email1 = $"{Guid.NewGuid():N}@sectest.example.com";
|
||||||
var email2 = $"{Guid.NewGuid():N}@sectest.example.com";
|
var email2 = $"{Guid.NewGuid():N}@sectest.example.com";
|
||||||
const string password = "TestPwd12345";
|
const string password = "TestPwd12345";
|
||||||
var hw1 = $"hw-{Guid.NewGuid():N}";
|
|
||||||
var hw2 = $"hw-{Guid.NewGuid():N}";
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -170,22 +168,20 @@ public sealed class SecurityTests
|
|||||||
up.IsSuccessStatusCode.Should().BeTrue();
|
up.IsSuccessStatusCode.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<byte[]> DownloadForAsync(string email, string hardware)
|
async Task<byte[]> DownloadForAsync(string email)
|
||||||
{
|
{
|
||||||
using var api = _fixture.CreateApiClient();
|
using var api = _fixture.CreateApiClient();
|
||||||
var token = await api.LoginAsync(email, password);
|
var token = await api.LoginAsync(email, password);
|
||||||
api.SetAuthToken(token);
|
api.SetAuthToken(token);
|
||||||
using var check = await api.PostAsync("/resources/check", new { hardware });
|
|
||||||
check.IsSuccessStatusCode.Should().BeTrue();
|
|
||||||
using var get = await api.PostAsync($"/resources/get/{folder}",
|
using var get = await api.PostAsync($"/resources/get/{folder}",
|
||||||
new { password, hardware, fileName });
|
new { password, fileName });
|
||||||
get.IsSuccessStatusCode.Should().BeTrue();
|
get.IsSuccessStatusCode.Should().BeTrue();
|
||||||
return await get.Content.ReadAsByteArrayAsync();
|
return await get.Content.ReadAsByteArrayAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var bytes1 = await DownloadForAsync(email1, hw1);
|
var bytes1 = await DownloadForAsync(email1);
|
||||||
var bytes2 = await DownloadForAsync(email2, hw2);
|
var bytes2 = await DownloadForAsync(email2);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
bytes1.Should().NotBeEquivalentTo(bytes2);
|
bytes1.Should().NotBeEquivalentTo(bytes2);
|
||||||
@@ -202,6 +198,21 @@ public sealed class SecurityTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Hardware_endpoints_are_removed_AZ_197()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var setHw = await admin.PutAsync("/users/hardware/set", new { Email = "x@y.com", Hardware = "any" });
|
||||||
|
using var checkHw = await admin.PostAsync("/resources/check", new { Hardware = "any" });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
setHw.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
checkHw.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Disabled_user_cannot_log_in()
|
public async Task Disabled_user_cannot_log_in()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user