refactor: remove deploy.cmd and update Dockerfile for health checks
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

- Deleted the deploy.cmd script as it was no longer needed.
- Updated Dockerfile to include curl for health checks and added a non-root user for improved security.
- Modified health check command to use curl for better reliability.
- Adjusted docker-compose.test.yml to reflect changes in health check configuration.
- Cleaned up appsettings.json and removed unused configuration properties.
- Removed Resource entity and related requests from the codebase as part of the architectural shift.
- Updated documentation to reflect the removal of hardware binding and related endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 08:47:21 +03:00
parent 43fe38e67d
commit c7b297de83
76 changed files with 4034 additions and 832 deletions
+8 -4
View File
@@ -62,10 +62,13 @@
| Entity | Description | Owned By Component |
|--------|-------------|--------------------|
| User | System user with email, password hash, role, config (legacy `Hardware` column tombstoned per AZ-197) | 01 Data Layer |
| User | System user with email (UNIQUE-indexed via `users_email_uidx`), password hash, role, config (legacy `Hardware` column tombstoned per AZ-197). Subset of users have `Role = CompanionPC` and are auto-provisioned via `POST /devices` (AZ-196), which delegates the insert to `UserService.RegisterUser` (post-security-audit consolidation, finding F-3). | 01 Data Layer |
| UserConfig | JSON-serialized per-user configuration (queue offsets) | 01 Data Layer |
| RoleEnum | Authorization role hierarchy (None → ApiAdmin) | 01 Data Layer |
| ExceptionEnum | Business error code catalog | Common Helpers |
| RoleEnum | Authorization role hierarchy (None → ApiAdmin); `ResourceUploader` retained as data only after the OTA endpoints were retired | 01 Data Layer |
| DetectionClass *(AZ-513, cycle 1)* | Operator-managed detection-class catalogue (Name, ShortName, Color, MaxSizeM, PhotoMode?) backing the UI Detection Classes table | 01 Data Layer |
| ExceptionEnum | Business error code catalog (HW-related codes 40/45 removed by AZ-197) | Common Helpers |
> **Removed in cycle 1 / post-cycle-1**: the `Resource` entity, the `resources` table, and the OTA delivery flow (AZ-183 — F10) were reverted after the security audit (finding F-1). The data model no longer carries an OTA-artifact entity.
**Key relationships**:
- User → RoleEnum: each user has exactly one role
@@ -112,9 +115,10 @@ No explicit availability, latency, throughput, or recovery targets found in the
**Authorization**: Role-based (RBAC) via ASP.NET Core authorization policies:
- `apiAdminPolicy` — requires `ApiAdmin` role
- `apiUploaderPolicy` — requires `ResourceUploader` or `ApiAdmin` (defined but never applied to any endpoint)
- General `[Authorize]` — any authenticated user
> 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).
- In transit: HTTPS (assumed, not enforced in code)
@@ -29,12 +29,14 @@
### 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`).
```
User:
Id: Guid (PK)
Email: string (required)
PasswordHash: string (required)
Hardware: string? (optional)
Hardware: string? (optional — TOMBSTONED by AZ-197; nullable, unused; no application code reads or writes)
Role: RoleEnum (required)
CreatedAt: DateTime (required)
LastLogin: DateTime? (optional)
@@ -49,7 +51,19 @@ UserQueueOffsets:
AnnotationsConfirmOffset: ulong
AnnotationsCommandsOffset: ulong
DetectionClass (AZ-513):
Id: int (PK, DB-assigned identity)
Name, ShortName, Color: string
MaxSizeM: double
PhotoMode: string?
CreatedAt: DateTime
// Resource entity — REMOVED post-cycle-1 (AZ-183 reverted). The `resources`
// table no longer exists; see env/db/ for the current migration set.
RoleEnum: None=0, Operator=10, Validator=20, CompanionPC=30, Admin=40, ResourceUploader=50, ApiAdmin=1000
// ResourceUploader is now data-only — no endpoint policy references it
// after AZ-183 was reverted.
```
### Configuration POCOs
@@ -69,6 +83,7 @@ ResourcesConfig:
ResourcesFolder: string
SuiteInstallerFolder: string
SuiteStageInstallerFolder: string
# EncryptionMasterKey was added by AZ-183 and removed in the post-cycle-1 revert.
```
## 3. External API Specification
@@ -81,23 +96,26 @@ N/A — internal component.
| Query | Frequency | Hot Path | Index Needed |
|-------|-----------|----------|--------------|
| `SELECT * FROM users WHERE email = ?` | High | Yes | Yes (email) |
| `SELECT * FROM users WHERE email = ?` | High | Yes | Yes — UNIQUE INDEX `users_email_uidx` on `email` (security audit F-3, `env/db/06_users_email_unique.sql`) |
| `SELECT * FROM users` with optional filters | Medium | No | No |
| `UPDATE users SET ... WHERE email = ?` | Medium | No | No |
| `INSERT INTO users` | Low | No | No |
| `INSERT INTO users` | Low | No | No (UNIQUE INDEX above also enforces single-row-per-email atomically) |
| `DELETE FROM users WHERE email = ?` | Low | No | No |
### Caching Strategy
| Data | Cache Type | TTL | Invalidation |
|------|-----------|-----|-------------|
| User by email | In-memory (LazyCache) | 4 hours | On hardware update, queue offset update, hardware check |
| User by email | In-memory (LazyCache) | 4 hours | On `UpdateQueueOffsets` (post-AZ-197 — hardware paths gone) |
> The `Resources.Latest.{arch}.{stage}` cache key (added by AZ-183) was removed in the post-cycle-1 revert.
### Storage Estimates
| Table | Est. Row Count (1yr) | Row Size | Total Size | Growth Rate |
|-------|---------------------|----------|------------|-------------|
| `users` | 1001000 | ~500 bytes | ~500 KB | Low |
| `users` | 1001000 web users + 200010000 CompanionPC device users (AZ-196 grows this) | ~500 bytes | ~5 MB | Medium (device fleet) |
| `detection_classes` (AZ-513) | 10200 | ~250 bytes | ~50 KB | Low |
### Data Management
@@ -116,7 +134,7 @@ N/A — internal component.
| linq2db | 5.4.1 | ORM for PostgreSQL access |
| Npgsql | 10.0.1 | PostgreSQL ADO.NET provider |
| LazyCache | 2.4.0 | In-memory cache with async support |
| Newtonsoft.Json | 13.0.1 | JSON serialization for UserConfig |
| Newtonsoft.Json | 13.0.4 | JSON serialization for UserConfig (bumped from 13.0.1 by security audit D-1, GHSA-5crp-9r3c-p9vr) |
**Error Handling Strategy**:
- `DbFactory.LoadOptions` throws `ArgumentException` on empty connection strings (fail-fast at startup).
@@ -167,7 +185,8 @@ N/A — internal component.
- `Common/Configs/ResourcesConfig`
- `Common/Entities/User`
- `Common/Entities/RoleEnum`
- `Common/Database/AzaionDb`
- `Common/Entities/DetectionClass` *(added cycle 1, AZ-513)*
- `Common/Database/AzaionDb` (now also holds the `DetectionClasses` table; the `Resources` ITable added by AZ-183 was removed in the post-cycle-1 revert)
- `Common/Database/AzaionDbSchemaHolder`
- `Common/Database/DbFactory`
- `Services/Cache`
@@ -1,12 +1,14 @@
# User Management
> **Cycle 1 (2026-05-13) note** — hardware-binding methods (`UpdateHardware`, `CheckHardwareHash`) and `SetHWRequest` were removed by AZ-197; the `ValidateUser` error set now includes `UserDisabled`; `RegisterDevice` was added by AZ-196 to back the new `POST /devices` endpoint. Post-cycle-1 (security audit F-3): `RegisterDevice` now reuses `RegisterUser` for the row insert; the duplicate-row race was closed by adding a UNIQUE INDEX on `users.email` (`env/db/06_users_email_unique.sql`) and translating `Npgsql.PostgresException(SqlState=23505)` to `BusinessException(EmailExists)` inside `RegisterUser`.
## 1. High-Level Overview
**Purpose**: Full user lifecycle management — registration, credential validation, hardware binding, role changes, account enable/disable, and deletion.
**Purpose**: Full user lifecycle management — web-user registration, credential validation, role changes, account enable/disable, deletion, plus auto-provisioning of CompanionPC device users.
**Architectural Pattern**: Service layer — stateless business logic operating on the Data Layer through `IDbFactory`.
**Upstream dependencies**: Data Layer (IDbFactory, ICache, User entity), Security & Cryptography (hashing).
**Upstream dependencies**: Data Layer (IDbFactory, ICache, User entity), Security & Cryptography (hashing), `System.Security.Cryptography.RandomNumberGenerator` (device password entropy).
**Downstream consumers**: Admin API (endpoint handlers), Authentication (GetByEmail).
@@ -16,18 +18,19 @@
| Method | Input | Output | Async | Error Types |
|--------|-------|--------|-------|-------------|
| `RegisterUser` | `RegisterUserRequest, CancellationToken` | void | Yes | `BusinessException(EmailExists)` |
| `ValidateUser` | `LoginRequest, CancellationToken` | `User` | Yes | `BusinessException(NoEmailFound, WrongPassword)` |
| `RegisterUser` | `RegisterUserRequest, CancellationToken` | void | Yes | `BusinessException(EmailExists)` — translated from `PostgresException(23505)` after the F-3 hardening |
| `RegisterDevice` | `CancellationToken` | `RegisterDeviceResponse` | Yes | `BusinessException(EmailExists)` (propagated from `RegisterUser`) — added by AZ-196, refactored post-audit to call `RegisterUser` end-to-end |
| `ValidateUser` | `LoginRequest, CancellationToken` | `User` | Yes | `BusinessException(NoEmailFound, WrongPassword, UserDisabled)` |
| `GetByEmail` | `string? email, CancellationToken` | `User?` | Yes | `ArgumentNullException` |
| `UpdateHardware` | `string email, string? hardware, CancellationToken` | void | Yes | None |
| `UpdateQueueOffsets` | `string email, UserQueueOffsets, CancellationToken` | void | Yes | None |
| `GetUsers` | `string? searchEmail, RoleEnum? searchRole, CancellationToken` | `IEnumerable<User>` | Yes | None |
| `CheckHardwareHash` | `User, string hardware, CancellationToken` | `string` (hash) | Yes | `BusinessException(HardwareIdMismatch)` |
| `ChangeRole` | `string email, RoleEnum, CancellationToken` | void | Yes | None |
| `SetEnableStatus` | `string email, bool, CancellationToken` | void | Yes | None |
| `RemoveUser` | `string email, CancellationToken` | void | Yes | None |
**Input DTOs**:
**Removed by AZ-197**: `UpdateHardware`, `CheckHardwareHash`, and the private `UpdateLastLoginDate` helper.
**Input / Output DTOs**:
```
RegisterUserRequest:
Email: string (required) — validated: min 8 chars, valid email format
@@ -38,9 +41,10 @@ LoginRequest:
Email: string (required)
Password: string (required)
SetHWRequest:
Email: string (required, validated: not empty)
Hardware: string? (optional — null clears hardware)
RegisterDeviceResponse (AZ-196):
Serial: string ("azj-NNNN", zero-padded)
Email: string ("azj-NNNN@azaion.com")
Password: string (32-char hex, plaintext, exposed exactly once)
SetUserQueueOffsetsRequest:
Email: string (required)
@@ -67,7 +71,7 @@ N/A — exposed through Admin API component.
| Data | Cache Type | TTL | Invalidation |
|------|-----------|-----|-------------|
| User by email | In-memory (via ICache) | 4 hours | After UpdateHardware, UpdateQueueOffsets, CheckHardwareHash (first login) |
| User by email | In-memory (via ICache) | 4 hours | After `UpdateQueueOffsets` (only — `UpdateHardware` / `CheckHardwareHash` invalidations are gone with AZ-197) |
## 5. Implementation Details
@@ -89,20 +93,21 @@ N/A — exposed through Admin API component.
| Helper | Purpose | Used By |
|--------|---------|---------|
| `Security.ToHash` | Password hashing (SHA-384) | RegisterUser, ValidateUser |
| `Security.GetHWHash` | Hardware fingerprint hashing | CheckHardwareHash |
| `Security.ToHash` | Password hashing (SHA-384) | RegisterUser, RegisterDevice, ValidateUser |
| `RandomNumberGenerator.GetBytes(16)` + `Convert.ToHexString` | 32-char hex device password | RegisterDevice |
| `QueryableExtensions.WhereIf` | Conditional LINQ filters | GetUsers |
## 7. Caveats & Edge Cases
**Known limitations**:
- No pagination on `GetUsers` — returns all matching users.
- `CheckHardwareHash` auto-stores hardware on first access (no explicit admin approval step).
- `RemoveUser` is a hard delete, not soft delete.
- `RegisterDevice` returns the plaintext password to the caller exactly once; if the provisioning script loses it, the device must be re-registered.
- The `User.Hardware` column is left in place but unused (AZ-197 chose to leave the column nullable rather than ship a migration).
**Potential race conditions**:
- Concurrent `RegisterUser` calls with the same email: both could pass the existence check before insert. Mitigated by database unique constraint on email (if one exists).
- `CheckHardwareHash` first-login path: concurrent requests could trigger multiple hardware updates.
- Concurrent `RegisterDevice` calls: both could read the same "most recent CompanionPC" row and try to claim the same `azj-NNNN` serial. Mitigated by the `users.email` unique constraint — the loser will fail the insert. (Out of cycle-1 scope: a sequence-based serial allocator would eliminate the retry.)
**Performance bottlenecks**:
- `GetUsers` loads full user objects including `UserConfig` JSON; for large user bases, projection would be more efficient.
@@ -123,5 +128,7 @@ No explicit logging in UserService.
- `Services/UserService`
- `Common/Requests/LoginRequest`
- `Common/Requests/RegisterUserRequest`
- `Common/Requests/SetHWRequest`
- `Common/Requests/RegisterDeviceResponse` *(added cycle 1, AZ-196)*
- `Common/Requests/SetUserQueueOffsetsRequest`
**Removed cycle 1 (AZ-197)**: `Common/Requests/SetHWRequest`
@@ -1,14 +1,16 @@
# 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).
## 1. High-Level Overview
**Purpose**: JWT token creation/validation and cryptographic utilities (password hashing, hardware fingerprint hashing, AES file encryption/decryption).
**Purpose**: JWT token creation/validation and cryptographic utilities (password hashing, AES file encryption/decryption).
**Architectural Pattern**: Service + static utility — `AuthService` is a DI-managed service for JWT operations; `Security` is a static class for cryptographic primitives.
**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, hardware hashing), 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), Resource Management (encryption key derivation, stream encryption).
## 2. Internal Interfaces
@@ -24,11 +26,12 @@
| Method | Input | Output | Description |
|--------|-------|--------|-------------|
| `ToHash` | `string` | `string` (Base64) | SHA-384 hash |
| `GetHWHash` | `string hardware` | `string` (Base64) | Salted hardware hash |
| `GetApiEncryptionKey` | `string email, string password, string? hwHash` | `string` (Base64) | Derives AES encryption key |
| `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.
## 3. External API Specification
N/A — exposed through Admin API.
@@ -62,11 +65,13 @@ 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.
- Hardware and encryption key salts are hardcoded constants.
- 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`.)
- `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.
@@ -1,12 +1,14 @@
# 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.
## 1. High-Level Overview
**Purpose**: Server-side file storage management — upload, list, download (with per-user AES encryption), folder clearing, and installer distribution.
**Purpose**: filesystem-backed storage — upload, list, download (per-user AES-encrypted), folder clearing, installer distribution. Owned by `IResourcesService`.
**Architectural Pattern**: Service layer — filesystem operations with encryption applied at the service boundary.
**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`), Authentication & Security (encryption via `Security.EncryptTo`).
**Downstream consumers**: Admin API (resource endpoints).
@@ -22,15 +24,15 @@
| `ListResources` | `string? dataFolder, string? search, CancellationToken` | `IEnumerable<string>` | Yes | `DirectoryNotFoundException` |
| `ClearFolder` | `string? dataFolder` | void | No | None |
**Input DTOs**:
**Input DTO**:
```
GetResourceRequest:
GetResourceRequest (post-AZ-197):
Password: string (required, min 8 chars)
Hardware: string (required, not empty)
FileName: string (required, not empty)
// Hardware field removed by AZ-197.
CheckResourceRequest:
Hardware: string (required)
// CheckResourceRequest — REMOVED by AZ-197.
// GetUpdateRequest, PublishResourceRequest — added by AZ-183, removed in the post-cycle-1 revert.
```
## 3. External API Specification
@@ -39,17 +41,21 @@ N/A — exposed through Admin API.
## 4. Data Access Patterns
No database access. All operations are filesystem-based.
`ResourcesService` is filesystem-only — no DB access, no cache.
| Source | Service | Pattern |
|--------|---------|---------|
| Filesystem (`ResourcesConfig.ResourcesFolder`) | `ResourcesService` | Direct read/write/delete |
### Storage Estimates
Resources are stored as flat files in configured directories. Size depends on uploaded content (AI models, DLLs, installers — potentially hundreds of MB per file).
- **Filesystem**: AI models, DLLs, installers — potentially hundreds of MB per file.
## 5. Implementation Details
**State Management**: Stateless — reads/writes directly to filesystem.
**State Management**: stateless — reads/writes directly to filesystem.
**Key Dependencies**: None beyond BCL (System.IO).
**Key Dependencies**: none beyond BCL (System.IO).
**Error Handling Strategy**:
- `SaveResource` throws `BusinessException(NoFileProvided)` for null uploads.
@@ -61,13 +67,13 @@ Resources are stored as flat files in configured directories. Size depends on up
| Helper | Purpose | Used By |
|--------|---------|---------|
| `Security.EncryptTo` | AES stream encryption | GetEncryptedResource |
| `Security.GetApiEncryptionKey` | Key derivation | Admin API (before calling GetEncryptedResource) |
| `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`) |
## 7. Caveats & Edge Cases
**Known limitations**:
- No path traversal protection: `dataFolder` parameter is concatenated directly with `ResourcesFolder`. A malicious `dataFolder` like `../../etc` could access arbitrary filesystem paths.
**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.
@@ -90,11 +96,11 @@ Resources are stored as flat files in configured directories. Size depends on up
|-----------|------|---------|
| INFO | Successful file save | `Resource {data.FileName} Saved Successfully` |
**Log format**: String interpolation via Serilog.
**Log format**: string interpolation via Serilog (security audit F-12 hygiene item: convert to structured form).
**Log storage**: Console + rolling file (via Serilog configured in Program.cs).
**Log storage**: console + rolling file (via Serilog configured in Program.cs).
## Modules Covered
- `Services/ResourcesService`
- `Common/Requests/GetResourceRequest` (includes CheckResourceRequest)
- `Common/Configs/ResourcesConfig`
- `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)
@@ -22,6 +22,8 @@ Converts `BusinessException` to HTTP 409 JSON response: `{ ErrorCode: int, Messa
## 3. External API Specification
> **Cycle 1 (2026-05-13) note** — endpoints below reflect the post-cycle-1 surface (AZ-513 Detection Classes CRUD, AZ-196 device auto-provisioning, AZ-197 hardware-binding removal). AZ-183 (OTA) shipped in cycle 1 but was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete. For per-endpoint cycle origins see `modules/admin_api_program.md`.
### Authentication
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
@@ -31,29 +33,41 @@ Converts `BusinessException` to HTTP 409 JSON response: `{ ErrorCode: int, Messa
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/users` | POST | ApiAdmin | Creates a new user |
| `/devices` | POST | ApiAdmin | **AZ-196**: provisions a CompanionPC device user (returns serial + email + plaintext password once) |
| `/users/current` | GET | Authenticated | Returns current user |
| `/users` | GET | ApiAdmin | Lists users (optional email/role filters) |
| `/users/hardware/set` | PUT | ApiAdmin | Sets user hardware |
| `/users/queue-offsets/set` | PUT | Authenticated | Updates queue offsets |
| `/users/{email}/set-role/{role}` | PUT | ApiAdmin | Changes user role |
| `/users/{email}/enable` | PUT | ApiAdmin | Enables user |
| `/users/{email}/disable` | PUT | ApiAdmin | Disables user |
| `/users/{email}` | DELETE | ApiAdmin | Removes user |
**Removed by AZ-197**: `PUT /users/hardware/set` (Hardware-binding feature deleted)
### Resource Management
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/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 |
| `/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 |
| `/resources/check` | POST | Authenticated | Validates hardware |
**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).
### Detection Classes
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/classes` | POST | ApiAdmin | **AZ-513**: creates a detection class |
| `/classes/{id:int}` | PATCH | ApiAdmin | **AZ-513**: partial-merge update of a detection class |
| `/classes/{id:int}` | DELETE | ApiAdmin | **AZ-513**: deletes a detection class |
### Authorization Policies
- **apiAdminPolicy**: requires `ApiAdmin` role (used on most admin endpoints)
- **apiUploaderPolicy**: requires `ResourceUploader` or `ApiAdmin` role (**defined but never applied to any endpoint — dead code**)
> 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.
### CORS
- Allowed origins: `https://admin.azaion.com`, `http://admin.azaion.com`
+5 -4
View File
@@ -52,10 +52,11 @@ These come from `_docs/02_document/components/` and exist for reading the codeba
| # | Sub-component | Primary file locations |
|---|----------------------|------------------------|
| 1 | Data Layer | `Azaion.Common/Database/`, `Azaion.Common/Configs/`, `Azaion.Common/Entities/` |
| 2 | User Management | `Azaion.Services/UserService.cs`, `Azaion.Common/Requests/{Create,Update,SetPassword,…}UserRequest.cs` |
| 3 | Auth & Security | `Azaion.Services/AuthService.cs`, `Azaion.Services/Security.cs`, `Azaion.Services/Cache.cs` |
| 4 | Resource Management | `Azaion.Services/ResourcesService.cs`, `Azaion.Common/Requests/{GetResource,CheckResources,…}.cs` |
| 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) |
| 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` |
## Allowed Dependencies (csproj layering)
+38 -21
View File
@@ -5,25 +5,40 @@ Application entry point: configures DI, middleware, authentication, authorizatio
## Public Interface (HTTP Endpoints)
| Method | Path | Auth | Summary |
|--------|------|------|---------|
| POST | `/login` | Anonymous | Validates credentials, returns JWT token |
| POST | `/users` | ApiAdmin | Creates a new user |
| GET | `/users/current` | Any authenticated | Returns current user from JWT claims |
| GET | `/users` | ApiAdmin | Lists users with optional email/role filters |
| PUT | `/users/hardware/set` | ApiAdmin | Sets a user's hardware fingerprint |
| PUT | `/users/queue-offsets/set` | Any authenticated | Updates user's queue offsets |
| PUT | `/users/{email}/set-role/{role}` | ApiAdmin | Changes a user's role |
| PUT | `/users/{email}/enable` | ApiAdmin | Enables a user account |
| PUT | `/users/{email}/disable` | ApiAdmin | Disables a user account |
| DELETE | `/users/{email}` | ApiAdmin | Removes a user |
| 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 |
| GET | `/resources/get-installer` | Any authenticated | Downloads latest production installer |
| GET | `/resources/get-installer/stage` | Any authenticated | Downloads latest staging installer |
| POST | `/resources/check` | Any authenticated | Validates hardware fingerprint |
> **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.
| Method | Path | Auth | Summary | Cycle 1 origin |
|--------|------|------|---------|----------------|
| POST | `/login` | Anonymous | Validates credentials, returns JWT token | — |
| POST | `/users` | ApiAdmin | Creates a new user | — |
| POST | `/devices` | ApiAdmin | Creates a CompanionPC device user (auto serial / email / 32-hex password) | AZ-196 |
| GET | `/users/current` | Any authenticated | Returns current user from JWT claims | — |
| GET | `/users` | ApiAdmin | Lists users with optional email/role filters | — |
| PUT | `/users/queue-offsets/set` | Any authenticated | Updates user's queue offsets | — |
| PUT | `/users/{email}/set-role/{role}` | ApiAdmin | Changes a user's role | — |
| PUT | `/users/{email}/enable` | ApiAdmin | Enables a user account | — |
| PUT | `/users/{email}/disable` | ApiAdmin | Disables a user account | — |
| DELETE | `/users/{email}` | ApiAdmin | Removes a user | — |
| 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
The following endpoints were removed during cycle 1 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 |
## Internal Logic
@@ -31,10 +46,11 @@ Application entry point: configures DI, middleware, authentication, authorizatio
- `IUserService``UserService` (Scoped)
- `IAuthService``AuthService` (Scoped)
- `IResourcesService``ResourcesService` (Scoped)
- `IDetectionClassService``DetectionClassService` (Scoped) — added by AZ-513
- `IDbFactory``DbFactory` (Singleton)
- `ICache``MemoryCache` (Scoped)
- `LazyCache` via `AddLazyCache()`
- FluentValidation validators auto-discovered from `RegisterUserValidator` assembly
- FluentValidation validators auto-discovered from `RegisterUserValidator` assembly (also picks up `CreateDetectionClassRequest`, `UpdateDetectionClassRequest` validators introduced in cycle 1)
- `BusinessExceptionHandler` registered as exception handler
### Middleware Pipeline
@@ -47,7 +63,8 @@ Application entry point: configures DI, middleware, authentication, authorizatio
### Authorization Policies
- `apiAdminPolicy`: requires `RoleEnum.ApiAdmin` role
- `apiUploaderPolicy`: requires `RoleEnum.ResourceUploader` OR `RoleEnum.ApiAdmin` role
> The `apiUploaderPolicy` (`RoleEnum.ResourceUploader` OR `ApiAdmin`) was added by AZ-183 and removed in the same cycle when the OTA endpoints it guarded were retired (see "Removed in cycle 1" above). `RoleEnum.ResourceUploader` itself remains as a data value (the seed `uploader@azaion.com` still uses it) but is no longer wired to any endpoint policy.
### Configuration Sections
- `JwtConfig` — JWT signing/validation
@@ -18,14 +18,15 @@ Custom exception type for domain-level errors, paired with an `ExceptionEnum` ca
| `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 (description text — actual validator threshold is 8 chars per `RegisterUserValidator`) |
| `EmailLengthIncorrect` | 35 | Email is empty or invalid |
| `WrongEmail` | 37 | (no description attribute) |
| `HardwareIdMismatch` | 40 | Hardware mismatch — unauthorized hardware |
| `BadHardware` | 45 | Hardware should be not empty |
| `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`.
## 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()`.
@@ -34,8 +35,8 @@ Static constructor eagerly loads all `ExceptionEnum` descriptions into a diction
## Consumers
- `BusinessExceptionHandler` — catches and serializes to HTTP 409 response
- `UserService` — throws for email/password/hardware validation failures
- `ResourcesService` — throws for missing file uploads
- `UserService` — throws for email/password validation failures (`NoEmailFound`, `WrongPassword`, `EmailExists`, `UserDisabled`)
- `ResourcesService` — throws `NoFileProvided` for missing file uploads
- FluentValidation validators — reference `ExceptionEnum` codes in `.WithErrorCode()`
## Data Models
@@ -0,0 +1,44 @@
# Module: Azaion.Common.Entities.DetectionClass
## Purpose
Domain entity for a single detection class shown to operators in the Detection Classes admin table. Persisted to the `detection_classes` table; managed via the `/classes` admin endpoints introduced by AZ-513.
> **Cycle 1 (2026-05-13) origin** — added by AZ-513 to back the new admin `/classes` CRUD endpoints; previously the read path was served by another service (likely `annotations/`) and admin/ had no own model for it.
## Public Interface
| Property | Type | Description |
|----------|------|-------------|
| `Id` | `int` | Auto-assigned identity (DB-generated via `InsertWithInt32IdentityAsync`) |
| `Name` | `string` | Full display name (max 120 chars per validator) |
| `ShortName` | `string` | Short label used in tight UI (max 20 chars) |
| `Color` | `string` | UI color (e.g. `"#FF0000"`, max 20 chars — accepts hex strings or named-color tokens) |
| `MaxSizeM` | `double` | Maximum real-world object size in meters (must be > 0) |
| `PhotoMode` | `string?` | Optional capture-mode hint (max 20 chars when present) |
| `CreatedAt` | `DateTime` | UTC creation timestamp set by the service on insert |
## Internal Logic
Plain POCO; no behaviour. Identity is assigned by the database on insert (`InsertWithInt32IdentityAsync`).
## Dependencies
None (no `using` directives on `Azaion.Services` / external libs).
## Consumers
- `Azaion.Services.DetectionClassService` — CRUD operations
- `AzaionDb.DetectionClasses` — linq2db table mapping (see `common_database_azaion_db.md`)
- `Azaion.AdminApi.Program``POST/PATCH/DELETE /classes` endpoints
## Data Models
Maps 1:1 to the `detection_classes` PostgreSQL table.
## Configuration
None.
## External Integrations
None directly; persisted via `IDbFactory` → PostgreSQL.
## Security
Data is operator-controlled metadata; no PII or secrets.
## Tests
- `e2e/Azaion.E2E/Tests/DetectionClassesTests.cs` — covers AZ-513 ACs 19
@@ -0,0 +1,51 @@
# Module: Azaion.Common.Requests.CreateDetectionClassRequest
## Purpose
Request DTO + FluentValidation validator for `POST /classes` (AZ-513).
> **Cycle 1 (2026-05-13) origin** — added by AZ-513.
## Public Interface
### CreateDetectionClassRequest
| Property | Type | Description |
|----------|------|-------------|
| `Name` | `string` | Full display name |
| `ShortName` | `string` | Short label |
| `Color` | `string` | UI color string (hex or named) |
| `MaxSizeM` | `double` | Max real-world size in meters |
| `PhotoMode` | `string?` | Optional capture-mode hint |
### CreateDetectionClassValidator
| Rule | Constraint |
|------|-----------|
| `Name` | NotEmpty, ≤ 120 chars |
| `ShortName` | NotEmpty, ≤ 20 chars |
| `Color` | NotEmpty, ≤ 20 chars |
| `MaxSizeM` | > 0 |
| `PhotoMode` | ≤ 20 chars when present |
## Internal Logic
Plain DTO; validator runs in the `/classes` POST handler before the service call. Validation failures are surfaced via `Results.ValidationProblem(...)` (HTTP 400).
## Dependencies
- FluentValidation
## Consumers
- `Azaion.AdminApi.Program` `POST /classes`
- `Azaion.Services.DetectionClassService.Create`
## Data Models
Maps to the writable subset of `DetectionClass` (see `common_entities_detection_class.md`).
## Configuration
None.
## External Integrations
None.
## Security
ApiAdmin-only endpoint; FluentValidation enforces field bounds. No HTML/JS sanitisation — the UI is responsible for safe rendering of `Name`, `ShortName`, `Color`.
## Tests
- e2e: `AC1_Post_classes_creates_class_with_assigned_id`, `AC2_Post_classes_*`
@@ -1,27 +1,22 @@
# Module: Azaion.Common.Requests.GetResourceRequest
## Purpose
Request DTOs and validator for resource access endpoints. Contains both `GetResourceRequest` and `CheckResourceRequest`.
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
### CheckResourceRequest
| Property | Type | Description |
|----------|------|-------------|
| `Hardware` | `string` | Hardware fingerprint to validate |
### GetResourceRequest
| Property | Type | Description |
|----------|------|-------------|
| `Password` | `string` | User's password (used to derive encryption key) |
| `Hardware` | `string` | Hardware fingerprint for authorization |
| `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` |
| `Hardware` not empty | Required | `BadHardware` |
| `FileName` not empty | Required | `WrongResourceName` |
## Internal Logic
@@ -32,7 +27,7 @@ Validator uses `BusinessException.GetMessage()` to derive user-facing error mess
- FluentValidation
## Consumers
- `Program.cs` `/resources/get/{dataFolder?}` and `/resources/check` endpoints
- `Program.cs` `POST /resources/get/{dataFolder?}` endpoint
## Data Models
None.
@@ -44,7 +39,8 @@ None.
None.
## Security
Password is sent in the POST body (not URL) to avoid logging in access logs. Hardware fingerprint validates device authorization.
- 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
None.
- `e2e/Azaion.E2E/Tests/ResourceTests.cs` (encrypted download / round-trip) — updated by AZ-197 to stop sending `Hardware`
@@ -0,0 +1,41 @@
# Module: Azaion.Common.Requests.RegisterDeviceResponse
## Purpose
Response DTO returned by `POST /devices` (AZ-196) — provides the provisioning script with the freshly-generated `Serial`, `Email`, and one-shot plaintext `Password` for a new CompanionPC device user.
> **Cycle 1 (2026-05-13) origin** — added by AZ-196.
## Public Interface
| Property | Type | Description |
|----------|------|-------------|
| `Serial` | `string` | Server-assigned device serial in the form `azj-NNNN` (zero-padded to 4 digits) |
| `Email` | `string` | `{Serial}@azaion.com` — the persisted user's login email |
| `Password` | `string` | Plaintext 32-char hex password — exposed exactly once at provisioning; never re-derivable from the SHA-384 hash that is persisted |
## Internal Logic
Plain POCO. All field values are produced inside `UserService.RegisterDevice` (see `services_user_service.md`).
## Dependencies
None.
## Consumers
- `Azaion.AdminApi.Program` `POST /devices` (returned via `Results.Ok(...)` implicit)
- `Azaion.Services.UserService.RegisterDevice` (constructs and returns the response)
- Provisioning script (out-of-tree) — embeds the values into `device.conf` on the Jetson
## Data Models
Mirrors a subset of fields written into the `users` row (`Email`, `PasswordHash`).
## Configuration
None.
## External Integrations
None.
## Security
- The `Password` is the only chance to capture the plaintext — once the response is consumed by the provisioning pipeline, the value cannot be recovered from the database (only the SHA-384 hash is persisted).
- The endpoint is gated by `apiAdminPolicy`. Treat the response as a credential — log carefully.
## Tests
- e2e: `AC1_Post_devices_returns_serial_email_and_password`, `AC3_Returned_credentials_can_login`
@@ -1,39 +0,0 @@
# Module: Azaion.Common.Requests.SetHWRequest
## Purpose
Request DTO and validator for setting a user's hardware fingerprint (`PUT /users/hardware/set`).
## Public Interface
### SetHWRequest
| Property | Type | Description |
|----------|------|-------------|
| `Email` | `string` | Target user's email |
| `Hardware` | `string?` | Hardware fingerprint (null clears it) |
### SetHWRequestValidator
| Rule | Constraint | Error Code |
|------|-----------|------------|
| `Email` not empty | Required | `EmailLengthIncorrect` |
## Dependencies
- `BusinessException`, `ExceptionEnum`
- FluentValidation
## Consumers
- `Program.cs` `/users/hardware/set` endpoint
## Data Models
None.
## Configuration
None.
## External Integrations
None.
## Security
None.
## Tests
None.
@@ -0,0 +1,51 @@
# Module: Azaion.Common.Requests.UpdateDetectionClassRequest
## Purpose
Request DTO + FluentValidation validator for `PATCH /classes/{id}` (AZ-513). All fields are nullable so callers may send the complete body OR only the changed fields — the service applies partial-merge semantics.
> **Cycle 1 (2026-05-13) origin** — added by AZ-513.
## Public Interface
### UpdateDetectionClassRequest
| Property | Type | Description |
|----------|------|-------------|
| `Name` | `string?` | If non-null, replace existing |
| `ShortName` | `string?` | If non-null, replace existing |
| `Color` | `string?` | If non-null, replace existing |
| `MaxSizeM` | `double?` | If non-null, replace existing |
| `PhotoMode` | `string?` | If non-null, replace existing |
### UpdateDetectionClassValidator
| Rule | Constraint (only checked when field is non-null) |
|------|--------------------------------------------------|
| `Name` | NotEmpty, ≤ 120 chars |
| `ShortName` | NotEmpty, ≤ 20 chars |
| `Color` | NotEmpty, ≤ 20 chars |
| `MaxSizeM` | > 0 |
| `PhotoMode` | ≤ 20 chars |
## Internal Logic
Each rule is gated by `.When(r => r.Field != null)` — fields the caller did not send pass validation untouched. The service then applies the same null-check pattern when writing back.
## Dependencies
- FluentValidation
## Consumers
- `Azaion.AdminApi.Program` `PATCH /classes/{id:int}`
- `Azaion.Services.DetectionClassService.Update`
## Data Models
Optional / partial view over `DetectionClass`.
## Configuration
None.
## External Integrations
None.
## Security
ApiAdmin-only endpoint. Per the AZ-513 spec, the UI sends the complete body on edit even though partial-merge is supported on the server — that keeps the implementer free to choose either policy without breaking the client.
## Tests
- e2e: `AC3_Patch_classes_full_body_updates_class`, `AC4_Patch_classes_partial_body_only_updates_specified_field`, `AC5_Patch_classes_unknown_id_returns_404`, `AC6_Patch_classes_without_jwt_returns_401`
@@ -0,0 +1,47 @@
# Module: Azaion.Services.DetectionClassService
## Purpose
CRUD service for `DetectionClass` rows backing the admin Detection Classes table. Wraps `IDbFactory.RunAdmin` calls and translates request DTOs into entity writes.
> **Cycle 1 (2026-05-13) origin** — added by AZ-513.
## Public Interface
### IDetectionClassService
| Method | Signature | Description |
|--------|-----------|-------------|
| `Create` | `Task<DetectionClass> Create(CreateDetectionClassRequest request, CancellationToken ct)` | Inserts a new class; returns the entity with the DB-assigned `Id` |
| `Update` | `Task<DetectionClass?> Update(int id, UpdateDetectionClassRequest request, CancellationToken ct)` | Partial-merge update; returns `null` when the id doesn't exist |
| `Delete` | `Task<bool> Delete(int id, CancellationToken ct)` | Returns `true` when at least one row was deleted; `false` when the id wasn't present |
## Internal Logic
- **Create**: instantiates `DetectionClass`, sets `CreatedAt = DateTime.UtcNow`, calls `db.InsertWithInt32IdentityAsync`, assigns the returned id back to the entity, returns it.
- **Update**: loads the row by id under the admin connection, returns `null` if missing. Otherwise applies a null-aware merge: each non-null property on the request overwrites the entity, then `db.UpdateAsync(existing)` persists the row. The route returns 404 when the service returns null.
- **Delete**: `db.DetectionClasses.DeleteAsync(x => x.Id == id, ct)`; returns `deleted > 0`. The route returns 404 when the service returns false.
All writes go through `IDbFactory.RunAdmin` (admin DB connection / role).
## Dependencies
- `IDbFactory` (`Azaion.Common.Database.IDbFactory`)
- `DetectionClass` entity
- `CreateDetectionClassRequest`, `UpdateDetectionClassRequest`
- `LinqToDB` extension methods (`FirstOrDefaultAsync`, `InsertWithInt32IdentityAsync`, `UpdateAsync`, `DeleteAsync`)
## Consumers
- `Azaion.AdminApi.Program``POST /classes`, `PATCH /classes/{id:int}`, `DELETE /classes/{id:int}` handlers
## Data Models
Operates on `DetectionClass` via `AzaionDb.DetectionClasses`.
## Configuration
None.
## External Integrations
PostgreSQL via `IDbFactory.RunAdmin`.
## Security
- All endpoints that delegate to this service require `apiAdminPolicy` at the route level.
- Validators run before the service (no extra defensive validation inside the service).
## Tests
- `e2e/Azaion.E2E/Tests/DetectionClassesTests.cs` — covers AZ-513 ACs 19
@@ -41,9 +41,9 @@ Uses `ResourcesConfig` (ResourcesFolder, SuiteInstallerFolder, SuiteStageInstall
Local filesystem for resource storage.
## Security
- Resources are encrypted per-user using a key derived from email + password + hardware hash
- File deletion overwrites existing files before writing new ones
- No path traversal protection on `dataFolder` parameter
- 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.
## Tests
None.
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.
+13 -14
View File
@@ -1,22 +1,22 @@
# Module: Azaion.Services.Security
## Purpose
Static utility class providing cryptographic operations: password hashing, hardware fingerprint hashing, encryption key derivation, and AES-CBC stream encryption/decryption.
Static utility class providing cryptographic operations: password hashing, encryption key derivation, and AES-CBC stream encryption/decryption.
> **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`.
## Public Interface
| Method | Signature | Description |
|--------|-----------|-------------|
| `ToHash` | `static string ToHash(this string str)` | Extension: SHA-384 hash of input, returned as Base64 |
| `GetHWHash` | `static string GetHWHash(string hardware)` | Derives a salted hash from hardware fingerprint string |
| `GetApiEncryptionKey` | `static string GetApiEncryptionKey(string email, string password, string? hardwareHash)` | Derives an AES encryption key from email + password + hardware hash |
| `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.
- **Hardware hashing**: `GetHWHash` salts the raw hardware string with `"Azaion_{hardware}_%$$$)0_"` before hashing.
- **Encryption key derivation**: `GetApiEncryptionKey` concatenates email, password, and hardware hash with a static salt, then hashes.
- **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.
@@ -25,10 +25,9 @@ Static utility class providing cryptographic operations: password hashing, hardw
- `System.Text.Encoding`
## Consumers
- `UserService.CheckHardwareHash` — calls `GetHWHash` to verify hardware fingerprint
- `Program.cs` `/resources/get` endpoint — calls `GetApiEncryptionKey`
- `Program.cs` `/resources/get/{dataFolder}` endpoint — calls `GetApiEncryptionKey(user.Email, request.Password)`
- `ResourcesService.GetEncryptedResource` — uses `EncryptTo` extension
- `SecurityTest` — directly tests `GetApiEncryptionKey`, `EncryptTo`, `DecryptTo`
- `Azaion.Test/SecurityTest` — directly tests `EncryptTo` / `DecryptTo` round-trips (no longer tests hardware-hash derivation)
## Data Models
None.
@@ -41,11 +40,11 @@ None.
## Security
Core cryptographic module. Key observations:
- Passwords are hashed with SHA-384 (no per-user salt, no key stretching — not bcrypt/scrypt/argon2)
- Hardware hash uses a static salt
- AES encryption uses SHA-256 of the derived key, with random IV per encryption
- All salts/prefixes are hardcoded constants
- 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.
## Tests
- `SecurityTest.EncryptDecryptTest` — round-trip encrypt/decrypt of a string
- `SecurityTest.EncryptDecryptLargeFileTest` — round-trip encrypt/decrypt of a ~400 MB generated file
- `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
@@ -1,7 +1,9 @@
# Module: Azaion.Services.UserService
## Purpose
Core business logic for user management: registration, authentication, hardware binding, role management, and account lifecycle.
Core business logic for user management: registration (web users + provisioned devices), authentication, role management, and account lifecycle.
> **Cycle 1 (2026-05-13) note** — hardware-binding methods (`UpdateHardware`, `CheckHardwareHash`, private `UpdateLastLoginDate`) and the bound `IUserService` declarations were removed by AZ-197 (admin-side hardware-binding cleanup). Device auto-provisioning (`RegisterDevice`) was added by AZ-196. **Post-cycle-1 (security audit F-3)**: `RegisterDevice` was refactored to delegate the row insert to `RegisterUser`, and `RegisterUser` itself now relies on the new `users_email_uidx` UNIQUE INDEX (`env/db/06_users_email_unique.sql`) — the check-then-insert race is gone; `Npgsql.PostgresException(SqlState=23505)` is translated to `BusinessException(EmailExists)`. See `_docs/03_implementation/batch_05_report.md` and `batch_06_report.md`.
## Public Interface
@@ -9,43 +11,45 @@ Core business logic for user management: registration, authentication, hardware
| Method | Signature | Description |
|--------|-----------|-------------|
| `RegisterUser` | `Task RegisterUser(RegisterUserRequest request, CancellationToken ct)` | Creates a new user with hashed password |
| `ValidateUser` | `Task<User> ValidateUser(LoginRequest request, CancellationToken ct)` | Validates email + password, returns user |
| `RegisterDevice` | `Task<RegisterDeviceResponse> RegisterDevice(CancellationToken ct)` | Creates a new `CompanionPC` user with auto-assigned `azj-NNNN` serial / email and a 32-char hex password (returned plaintext exactly once) |
| `ValidateUser` | `Task<User> ValidateUser(LoginRequest request, CancellationToken ct)` | Validates email + password, returns user. Throws `NoEmailFound`, `WrongPassword`, or `UserDisabled` |
| `GetByEmail` | `Task<User?> GetByEmail(string? email, CancellationToken ct)` | Cached user lookup by email |
| `UpdateHardware` | `Task UpdateHardware(string email, string? hardware, CancellationToken ct)` | Sets/clears user's hardware fingerprint |
| `UpdateQueueOffsets` | `Task UpdateQueueOffsets(string email, UserQueueOffsets offsets, CancellationToken ct)` | Updates user's annotation queue offsets |
| `GetUsers` | `Task<IEnumerable<User>> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct)` | Lists users with optional email/role filters |
| `CheckHardwareHash` | `Task<string> CheckHardwareHash(User user, string hardware, CancellationToken ct)` | Validates or initializes hardware binding |
| `ChangeRole` | `Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct)` | Changes a user's role |
| `SetEnableStatus` | `Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct)` | Enables or disables a user account |
| `RemoveUser` | `Task RemoveUser(string email, CancellationToken ct)` | Permanently deletes a user |
## Internal Logic
- **RegisterUser**: checks for duplicate email, hashes password via `Security.ToHash`, inserts via `RunAdmin`.
- **ValidateUser**: finds user by email, compares password hash. Throws `NoEmailFound` or `WrongPassword`.
- **RegisterUser**: hashes password via `Security.ToHash`, inserts via `RunAdmin`. Catches `Npgsql.PostgresException` with `SqlState == PostgresErrorCodes.UniqueViolation` (23505) on the `users_email_uidx` UNIQUE INDEX and rethrows as `BusinessException(EmailExists)`. The previous check-then-insert pattern was removed (race-prone before the index existed; redundant after).
- **RegisterDevice**: calls private `NextDeviceIdentity` (read-only) to compute the next `azj-NNNN` serial + matching email, generates a 32-char hex password from `RandomNumberGenerator.GetBytes(16)`, then delegates the row insert to `RegisterUser` (so any future change to user-creation policy applies here too). Returns `{Serial, Email, Password}` (plaintext password exposed exactly once at provisioning time). On a serial-allocation race, the second caller's insert hits the UNIQUE INDEX and surfaces `BusinessException(EmailExists)`; the caller can retry.
- **NextDeviceIdentity** (private): queries the most recent `RoleEnum.CompanionPC` user via `dbFactory.Run` (read connection), parses the `azj-NNNN` suffix (chars `[SerialNumberStart, SerialNumberLength)` of the email, constants on the class), increments by 1, returns `(serial, email)`.
- **ValidateUser**: finds user by email, compares password hash. Throws `NoEmailFound`, `WrongPassword`, or `UserDisabled`.
- **GetByEmail**: uses `ICache.GetFromCacheAsync` with key `User.{email}`.
- **CheckHardwareHash**: on first access (null hardware), stores the raw hardware string and returns the hash. On subsequent access, compares hashes. Throws `HardwareIdMismatch` on mismatch. Also updates `LastLogin` timestamp.
- **UpdateHardware/UpdateQueueOffsets**: use `RunAdmin` for writes, then invalidate cache.
- **UpdateQueueOffsets**: writes via `RunAdmin`, then invalidates the user cache.
- **GetUsers**: uses `WhereIf` for optional filter predicates.
Private method:
- `UpdateLastLoginDate` — updates `LastLogin` to `DateTime.UtcNow`.
Private constants (device provisioning):
- `DeviceEmailPrefix = "azj-"`, `DeviceEmailDomain = "@azaion.com"`, `SerialNumberStart = 4`, `SerialNumberLength = 4`, `DevicePasswordBytes = 16`.
## Dependencies
- `IDbFactory` (database access)
- `ICache` (user caching)
- `Security` (hashing)
- `Security` (hashing`ToHash`)
- `System.Security.Cryptography.RandomNumberGenerator` (device password entropy)
- `Npgsql` (`PostgresException`, `PostgresErrorCodes.UniqueViolation` — used to translate UNIQUE-INDEX violations to `BusinessException(EmailExists)`)
- `BusinessException` (domain errors)
- `QueryableExtensions.WhereIf`
- `User`, `UserConfig`, `UserQueueOffsets`, `RoleEnum`
- `RegisterUserRequest`, `LoginRequest`
- `RegisterUserRequest`, `LoginRequest`, `RegisterDeviceResponse`
## Consumers
- `Program.cs` all `/users/*` endpoints delegate to `IUserService`
- `Program.cs``/users/*` endpoints delegate to `IUserService`
- `Program.cs``POST /devices` calls `RegisterDevice` (added by AZ-196)
- `AuthService.GetCurrentUser` — calls `GetByEmail`
- `Program.cs` `/resources/get` — calls `CheckHardwareHash`
## Data Models
Operates on `User` entity via `AzaionDb.Users` table.
Operates on `User` entity via `AzaionDb.Users` table. The `User.Hardware` column is left in place (nullable, unused) per AZ-197 — see the entity doc.
## Configuration
None.
@@ -54,9 +58,10 @@ None.
PostgreSQL via `IDbFactory`.
## Security
- Passwords hashed with SHA-384 (via `Security.ToHash`) before storage
- Hardware binding prevents resource access from unauthorized devices
- Read operations use read-only DB connection; writes use admin connection
- Passwords hashed with SHA-384 (via `Security.ToHash`) before storage.
- Device passwords are returned plaintext to the caller exactly once at provisioning; the persisted form is the SHA-384 hash. The plaintext is never re-derivable.
- Read operations use the read-only DB connection; writes use the admin connection.
## Tests
- `UserServiceTest.CheckHardwareHashTest`integration test against live database
- `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
+139
View File
@@ -0,0 +1,139 @@
# Ripple Log — Cycle 1 (2026-05-13)
Documentation refresh triggered by the cycle 1 task set: AZ-513, AZ-196, AZ-183, AZ-197.
> **Post-cycle-1 update (same day, 2026-05-13)** — after the security audit (autodev Step 14) AZ-183 (OTA update check & publish) was reverted in full and a F-3 hardening pass was applied to `RegisterUser`/`RegisterDevice`. See "Post-cycle-1 revert (security audit follow-up)" at the bottom of this log for the doc deltas.
This log records every doc that was refreshed (directly or via the import-graph ripple from another changed file) during autodev Step 13 (Update Docs) — `document` skill in **Task mode**.
## Method
Per `.cursor/skills/document/workflows/task.md` Step 0.5, for each changed source file the consuming files were located via `using` references inside `Azaion.AdminApi/`, `Azaion.Services/`, `Azaion.Common/`, `Azaion.Test/`, and `e2e/Azaion.E2E/`. Each consumer that lives in an already-documented module triggered a doc refresh.
For C#, the import surface walked was `using Azaion.{Common,Services}*;` plus `ProjectReference` declarations in the four production csprojs (`Azaion.AdminApi`, `Azaion.Services`, `Azaion.Common`, `Azaion.Test`, `e2e/Azaion.E2E`).
## Direct refreshes (changed source file → existing module doc)
| Module Doc | Trigger |
|------------|---------|
| `modules/services_user_service.md` | `Azaion.Services/UserService.cs` — AZ-196 added `RegisterDevice`; AZ-197 removed `UpdateHardware`, `CheckHardwareHash`, `UpdateLastLoginDate`. |
| `modules/services_security.md` | `Azaion.Services/Security.cs` — AZ-197 removed `GetHWHash`; `GetApiEncryptionKey` signature simplified. |
| `modules/services_resources_service.md` | `Azaion.Services/ResourcesService.cs` — caller-side hardware path gone (security note rewrite). |
| `modules/common_requests_get_resource.md` | `Azaion.Common/Requests/GetResourceRequest.cs` — AZ-197 removed `Hardware` field; `CheckResourceRequest` removed. |
| `modules/common_business_exception.md` | `Azaion.Common/BusinessException.cs` — AZ-197 removed `HardwareIdMismatch` (40) and `BadHardware` (45). |
| `modules/admin_api_program.md` | `Azaion.AdminApi/Program.cs` — AZ-513 added `/classes` CRUD; AZ-196 added `/devices`; AZ-183 added `/get-update` + `/resources/publish`; AZ-197 removed `/users/hardware/set` and `/resources/check`. |
| `modules/common_requests_set_hw.md` | **Deleted**`Azaion.Common/Requests/SetHWRequest.cs` no longer exists. |
## New module docs (added cycle 1)
| Module Doc | New Source File |
|------------|-----------------|
| `modules/common_entities_detection_class.md` | `Azaion.Common/Entities/DetectionClass.cs` (AZ-513) |
| `modules/common_entities_resource.md` | `Azaion.Common/Entities/Resource.cs` (AZ-183) |
| `modules/common_requests_create_detection_class.md` | `Azaion.Common/Requests/CreateDetectionClassRequest.cs` (AZ-513) |
| `modules/common_requests_update_detection_class.md` | `Azaion.Common/Requests/UpdateDetectionClassRequest.cs` (AZ-513) |
| `modules/services_detection_class_service.md` | `Azaion.Services/DetectionClassService.cs` (AZ-513) |
| `modules/services_resource_update_service.md` | `Azaion.Services/ResourceUpdateService.cs` (AZ-183) |
| `modules/common_requests_get_update.md` | `Azaion.Common/Requests/GetUpdateRequest.cs` (AZ-183 — also defines `ResourceUpdateItem`) |
| `modules/common_requests_publish_resource.md` | `Azaion.Common/Requests/PublishResourceRequest.cs` (AZ-183) |
| `modules/common_requests_register_device_response.md` | `Azaion.Common/Requests/RegisterDeviceResponse.cs` (AZ-196) |
## Component-level refreshes (parents of refreshed modules)
| Component Doc | Reason |
|---------------|--------|
| `components/01_data_layer/description.md` | New entities (`DetectionClass`, `Resource`); new cache key `Resources.Latest.{arch}.{stage}`; storage estimates updated; `User.Hardware` marked tombstoned. |
| `components/02_user_management/description.md` | `RegisterDevice` added to interface table; `CheckHardwareHash` / `UpdateHardware` removed from interface table; `SetHWRequest` removed; cache invalidation table simplified. |
| `components/03_auth_and_security/description.md` | `Security.GetApiEncryptionKey` signature simplified; `GetHWHash` removed. |
| `components/04_resource_management/description.md` | `IResourceUpdateService` added (AZ-183) with separate DB + cache + at-rest column encryption; `GetResourceRequest` no longer carries `Hardware`; `CheckResourceRequest` removed. |
| `components/05_admin_api/description.md` | New endpoints (POST `/classes`, PATCH `/classes/{id}`, DELETE `/classes/{id}`, POST `/devices`, POST `/get-update`, POST `/resources/publish`); removed endpoints (PUT `/users/hardware/set`, POST `/resources/check`); `apiUploaderPolicy` is now in use. |
## System-level refreshes
| System Doc | Reason |
|------------|--------|
| `system-flows.md` | F4 (Hardware Check) marked REMOVED; F3 sequence diagram regenerated without hardware step; F8 (Detection Classes CRUD), F9 (Device Auto-Provisioning), F10 (OTA Update Check & Publish) added with full sequence diagrams + error tables. |
| `architecture.md` | Data Model Overview lists the new `DetectionClass` and `Resource` entities; the `User` entity caption notes the CompanionPC subset auto-provisioned via AZ-196; ExceptionEnum caption notes HW-related codes are gone. The `Note (AZ-197)` block at the top was already in place pre-Step-13. |
| `module-layout.md` | Conceptual Sub-Components table updated: cycle-1-added files annotated; `SetHWRequest` removal noted; new sub-component `4b Detection Classes` added. |
| `diagrams/flows/flow_hardware_check.md` | Already converted to a tombstone during AZ-197 implementation; no further action this cycle. |
## Tooling notes
- C# import resolution was performed by `Grep` on `using Azaion.*` patterns plus by reading the `.csproj` `ProjectReference` set, since the workspace has no `madge`/`depcruise`-equivalent statically available. Any consumer in `Azaion.AdminApi/Program.cs` was treated as a "system entry point" consumer (Program.cs is the composition root + endpoint table — a single file that legitimately consumes everything).
- Tests under `Azaion.Test/` and `e2e/Azaion.E2E/` were considered downstream consumers of `Azaion.Services` and `Azaion.Common`. Their files were NOT promoted into the doc tree (per `module-layout.md` Layout Rules — tests are not public API surface), but their AC coverage was reflected in module-doc "Tests" sections and in `tests/blackbox-tests.md` / `tests/traceability-matrix.md` (autodev Step 12).
## No-op observations
- Other module docs in `_docs/02_document/modules/` (e.g., `common_entities_user.md`, `common_database_*.md`, `common_extensions_*.md`, `services_auth_service.md`, `services_cache.md`, `admin_api_business_exception_handler.md`, `common_requests_login_request.md`, `common_requests_register_user.md`, `common_requests_set_queue_offsets.md`, `common_configs_*.md`) were inspected and found to be unaffected by cycle 1 changes — no refresh needed.
- `_docs/00_problem/acceptance_criteria.md` and `_docs/00_problem/restrictions.md` were intentionally NOT modified — Task-mode Step 4 only updates problem-level docs when the task changed input parameters or the AC catalogue. Cycle 1 added new behaviours but the baseline AC numbering (AC-1..AC-28) is preserved per `cycle-update` rules; new AC sets live under their tracker IDs in `tests/traceability-matrix.md`.
---
## Post-cycle-1 revert (security audit follow-up, 2026-05-13)
After autodev Step 14 (Security Audit) finished with verdict **FAIL** (3 open Highs: F-1, F-2, F-3), the user instructed:
> "fix findings right now F-1 get-update is again leftover from the shipping resources era, when we delivered software as an installer. We don't need now IResourceUpdateService. F-3 (AMPLIFIED, AZ-196) — duplicate-email race now reachable on /devices because users.email has no UNIQUE index. first of all, reuse the code in the implementation RegisterDevice -> should call RegisterUser then add index to email"
### Code changes
| File | Action | Reason |
|------|--------|--------|
| `Azaion.Services/ResourceUpdateService.cs` | Deleted | F-1 — entire OTA feature reverted |
| `Azaion.Common/Requests/GetUpdateRequest.cs` | Deleted | F-1 — request DTO unused after endpoint deletion |
| `Azaion.Common/Requests/PublishResourceRequest.cs` | Deleted | F-1 — request DTO unused after endpoint deletion |
| `Azaion.Common/Entities/Resource.cs` | Deleted | F-1 — entity unused after service deletion |
| `env/db/05_resources.sql` | Deleted | F-1 — `resources` table no longer needed |
| `e2e/Azaion.E2E/Tests/ResourceUpdateTests.cs` | Deleted | F-1 — covers deleted endpoints |
| `Azaion.AdminApi/Program.cs` | Edited | F-1 — removed `/get-update`, `/resources/publish`, `IResourceUpdateService` DI registration, `apiUploaderPolicy` |
| `Azaion.Common/Database/AzaionDb.cs` | Edited | F-1 — removed `ITable<Resource>` |
| `Azaion.Common/Database/AzaionDbShemaHolder.cs` | Edited | F-1 — removed `Resource` entity mapping |
| `Azaion.Common/Configs/ResourcesConfig.cs` | Edited | F-1 — removed `EncryptionMasterKey` field (also closes F-5) |
| `Azaion.AdminApi/appsettings.json` | Edited | F-1 — removed `EncryptionMasterKey` config value |
| `docker-compose.test.yml` | Edited | F-1 — removed `ResourcesConfig__EncryptionMasterKey` env var |
| `env/db/06_users_email_unique.sql` | **Created** | F-3 — `CREATE UNIQUE INDEX users_email_uidx ON public.users (email);` |
| `e2e/db-init/00_run_all.sh` | Edited | drop `05_resources.sql` line; add `06_users_email_unique.sql` line |
| `Azaion.Services/UserService.cs` | Edited | F-3 — `RegisterUser` drops check-then-insert, catches `Npgsql.PostgresException(SqlState=23505)``EmailExists`; `RegisterDevice` now delegates the row insert to `RegisterUser` (per user direction) |
### Doc deltas
| Doc | What changed |
|-----|--------------|
| `system-flows.md` | F10 row in flow inventory marked REMOVED; F9 dependency note updated; full F10 section replaced with a tombstone explaining the revert |
| `architecture.md` | `Resource` entity removed from data model table; `User` row notes UNIQUE INDEX on email and the `RegisterDevice``RegisterUser` consolidation |
| `module-layout.md` | `4 Resource Management` row updated to drop OTA files; `2 User Management` row notes the F-3 consolidation |
| `components/01_data_layer/description.md` | `Resource` entity removed; UNIQUE INDEX on email noted; `Resources.Latest.*` cache key removed; storage-estimates row removed; Newtonsoft.Json version bumped to 13.0.4 |
| `components/02_user_management/description.md` | `RegisterUser` and `RegisterDevice` rows updated to reflect the F-3 fix |
| `components/04_resource_management/description.md` | Rewritten — collapsed back to filesystem-storage scope; OTA references removed; F-2 callout retained as known limitation |
| `components/05_admin_api/description.md` | `/get-update`, `/resources/publish`, `apiUploaderPolicy` removed from endpoint and policy tables |
| `modules/admin_api_program.md` | Endpoint table no longer lists OTA endpoints; "Removed in cycle 1" section absorbs them; DI list and policies updated |
| `modules/services_user_service.md` | F-3 fix detailed in Internal Logic; Npgsql added to Dependencies |
| `modules/services_resource_update_service.md` | **Deleted** |
| `modules/common_entities_resource.md` | **Deleted** |
| `modules/common_requests_get_update.md` | **Deleted** |
| `modules/common_requests_publish_resource.md` | **Deleted** |
| `tests/traceability-matrix.md` | AZ-183 section marked REVERTED; FT-P-21..23 strikethroughs |
| `tests/blackbox-tests.md` | OTA section collapsed to ID-placeholder table; bodies removed |
| `_docs/05_security/security_report.md` | Verdict flipped from FAIL → PASS_WITH_WARNINGS; F-1, F-3, D-1 marked CLOSED; F-2 deferred |
| `_docs/05_security/static_analysis.md` | F-1, F-3, F-5 marked CLOSED with resolution notes |
| `_docs/05_security/owasp_review.md` | A01 / A02 / A04 / A07 categories upgraded to PASS_WITH_WARNINGS or PASS where the only failing finding was a now-closed cycle-1 entry |
| `_docs/05_security/dependency_scan.md` | (already updated during the audit) D-1 marked RESOLVED |
### Verification
- `dotnet build Azaion.AdminApi/Azaion.AdminApi.csproj` — green, 0 warnings.
- `dotnet test Azaion.Test/Azaion.Test.csproj` — 2/2 passed.
- `./scripts/run-tests.sh` (e2e) — 44/44 passed (down from 48/48; the 4 deleted `ResourceUpdateTests` are accounted for).
### Follow-up tickets filed in Jira
| Ticket | Title | Points |
|--------|-------|--------|
| [AZ-516](https://denyspopov.atlassian.net/browse/AZ-516) | F-2: Sanitize `dataFolder` route segment to prevent path traversal | 3 |
| [AZ-517](https://denyspopov.atlassian.net/browse/AZ-517) | F-4: Harden `/devices` response (Cache-Control, runbook) | 2 |
| [AZ-518](https://denyspopov.atlassian.net/browse/AZ-518) | F-6: Run admin API container as non-root | 2 |
| [AZ-519](https://denyspopov.atlassian.net/browse/AZ-519) | F-7: Migrate password hashing to Argon2id with per-user salt | 5 |
| [AZ-520](https://denyspopov.atlassian.net/browse/AZ-520) | F-8: Add rate limiting to `/login` endpoint | 2 |
| [AZ-521](https://denyspopov.atlassian.net/browse/AZ-521) | Low-severity security hygiene bundle (F-9, F-11, F-12, F-13) | 3 |
A revert comment was added to AZ-183 (the OTA task that was deleted as part of the F-1 fix).
+139 -53
View File
@@ -1,5 +1,7 @@
# 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.
## Flow Inventory
| # | Flow Name | Trigger | Primary Components | Criticality |
@@ -7,22 +9,26 @@
| 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 |
| F4 | Hardware Check | POST /resources/check | Admin API, Auth, User Mgmt | High |
| ~~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 |
| 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 |
| ~~F10~~ | ~~OTA Update Check & Publish~~ | ~~POST /get-update + POST /resources/publish~~ | — | **REMOVED — post-cycle-1 (AZ-183 reverted, see security audit F-1)** |
## Flow Dependencies
| Flow | Depends On | Shares Data With |
|------|-----------|-----------------|
| F1 | — | All other flows (produces JWT token) |
| F2 | — | F1, F3, F4 (creates user records) |
| F3 | F1 (requires JWT), F4 (hardware must be bound) | F4 (via hardware hash) |
| F4 | F1 (requires JWT) | F3 (hardware binding) |
| 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, F4 (user data) |
| F7 | F1 (requires JWT, ApiAdmin role) | F3 (user data) |
| 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) |
---
@@ -108,12 +114,13 @@ sequenceDiagram
## Flow F3: Encrypted Resource Download
> **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 validates hardware binding, derives a per-user encryption key, encrypts the file with AES-256-CBC, and streams the encrypted content.
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)
- User's hardware is bound (via prior F4 call)
- Resource file exists on server
### Sequence Diagram
@@ -123,20 +130,15 @@ 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}
Client->>API: POST /resources/get {password, 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->>Sec: GetApiEncryptionKey(email, password)
Sec-->>API: AES key string
API->>RS: GetEncryptedResource(folder, fileName, key)
RS->>FS: Read file
FS-->>RS: FileStream
@@ -151,48 +153,15 @@ sequenceDiagram
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Not authenticated | API | No/invalid JWT | 401 Unauthorized |
| Hardware mismatch | UserService.CheckHardwareHash | Hash comparison fails | 409: HardwareIdMismatch (code 40) |
| File not found | ResourcesService | FileStream throws | 500 Internal Server Error |
---
## Flow F4: Hardware Check (First Login / Validation)
## Flow F4: Hardware Check (REMOVED by AZ-197)
### Description
Client submits its hardware fingerprint. On first call, the hardware is stored for the user. On subsequent calls, the stored hash is compared against the provided hardware.
The hardware-fingerprint binding flow (`POST /resources/check`, `UserService.CheckHardwareHash`, `Security.GetHWHash`, error code 40 `HardwareIdMismatch`, error code 45 `BadHardware`) was removed entirely in cycle 1.
### Preconditions
- User is authenticated (JWT)
### Sequence Diagram
```mermaid
sequenceDiagram
participant Client
participant API as Admin API
participant Auth as AuthService
participant US as UserService
participant DB as PostgreSQL
Client->>API: POST /resources/check {hardware}
API->>Auth: GetCurrentUser()
Auth-->>API: User
API->>US: CheckHardwareHash(user, hardware)
alt First time (no stored hardware)
US->>DB: UPDATE user SET hardware = ? (admin conn)
US->>DB: UPDATE user SET last_login = now()
US-->>API: hwHash
else Hardware already bound
US->>US: Compare hashes
alt Match
US->>DB: UPDATE user SET last_login = now()
US-->>API: hwHash
else Mismatch
US-->>API: throw HardwareIdMismatch
end
end
API-->>Client: 200 OK (true) / 409
```
Reason: the threat the binding mitigated (credential reuse via desktop installers) was eliminated by the architectural shift to fTPM-secured Jetsons + browser-only SaaS access. See `_docs/03_implementation/batch_06_report.md` and the obsolete diagram `diagrams/flows/flow_hardware_check.md`.
---
@@ -260,9 +229,126 @@ sequenceDiagram
## Flow F7: User Management (CRUD)
### Description
Admin operations: list users, change role, enable/disable, set hardware, update queue offsets, delete user.
Admin operations: list users, change role, enable/disable, update queue offsets, delete user. (The "set hardware" operation was removed by AZ-197 — see F4.)
### Preconditions
- Caller has ApiAdmin role (for most operations)
All operations follow the same pattern: API endpoint → UserService method → DbFactory.RunAdmin → PostgreSQL UPDATE/DELETE. Cache is invalidated for affected user keys after writes.
All operations follow the same pattern: API endpoint → UserService method → DbFactory.RunAdmin → PostgreSQL UPDATE/DELETE. Cache is invalidated for affected user keys after writes (the `UpdateQueueOffsets` path is the only remaining cache-invalidation site post-AZ-197).
---
## Flow F8: Detection Classes CRUD *(AZ-513, 2026-05-13)*
### Description
ApiAdmin manages the detection-class catalogue exposed to operators in the UI: create new entries, partial-merge edits, delete entries. The UI's existing add/delete affordances start working end-to-end once this flow exists; the in-place edit affordance arrives via UI cycle AZ-512.
### Preconditions
- Caller has ApiAdmin role (`apiAdminPolicy`)
- `detection_classes` table exists in the admin DB
### Sequence Diagram
```mermaid
sequenceDiagram
participant Client
participant API as Admin API
participant VAL as FluentValidation
participant DCS as DetectionClassService
participant DB as PostgreSQL
Client->>API: POST /classes {name, shortName, color, maxSizeM, photoMode?}
API->>VAL: Validate CreateDetectionClassRequest
VAL-->>API: OK / 400
API->>DCS: Create(request)
DCS->>DB: InsertWithInt32IdentityAsync (admin conn)
DB-->>DCS: new id
DCS-->>API: DetectionClass {id, …}
API-->>Client: 200 OK {DetectionClass}
Client->>API: PATCH /classes/{id} {…partial fields}
API->>VAL: Validate UpdateDetectionClassRequest
VAL-->>API: OK / 400
API->>DCS: Update(id, request)
alt id exists
DCS->>DB: UPDATE row applying non-null fields (admin conn)
DCS-->>API: DetectionClass
API-->>Client: 200 OK {DetectionClass}
else id missing
DCS-->>API: null
API-->>Client: 404 Not Found
end
Client->>API: DELETE /classes/{id}
API->>DCS: Delete(id)
DCS->>DB: DELETE WHERE id = ? (admin conn)
alt deleted > 0
DCS-->>API: true
API-->>Client: 204 No Content
else
DCS-->>API: false
API-->>Client: 404 Not Found
end
```
### Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Not authenticated | API | No JWT | 401 Unauthorized |
| Wrong role | API | Non-ApiAdmin JWT | 403 Forbidden |
| Validation failure | FluentValidation | Field bounds violated | 400 Bad Request |
| Missing id (PATCH/DELETE) | DetectionClassService | Row not found | 404 Not Found |
---
## Flow F9: Device Auto-Provisioning *(AZ-196, 2026-05-13)*
### Description
ApiAdmin requests a fresh CompanionPC device user. The server allocates the next sequential serial (`azj-NNNN`), generates a 32-char hex password, persists the user with the SHA-384 hash, and returns the plaintext credentials exactly once. The provisioning script (out-of-tree) embeds the values into the device's `device.conf`.
### Preconditions
- Caller has ApiAdmin role (`apiAdminPolicy`)
### Sequence Diagram
```mermaid
sequenceDiagram
participant Admin
participant API as Admin API
participant US as UserService
participant DB as PostgreSQL
Admin->>API: POST /devices (no body)
API->>US: RegisterDevice()
US->>DB: SELECT TOP 1 email FROM users WHERE role = 'CompanionPC' ORDER BY created_at DESC
DB-->>US: lastEmail (or null)
US->>US: nextNumber = parse(lastEmail.suffix) + 1 (or 0)
US->>US: serial = "azj-" + nextNumber.PadLeft(4)
US->>US: password = ToHex(RandomBytes(16)) // 32 hex chars
US->>DB: INSERT user {Email=serial@domain, PasswordHash=SHA384(password), Role=CompanionPC, IsEnabled=true} (admin conn)
DB-->>US: OK
US-->>API: RegisterDeviceResponse {Serial, Email, Password}
API-->>Admin: 200 OK {Serial, Email, Password}
```
### Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Not authenticated / wrong role | API | JWT missing or non-ApiAdmin | 401 / 403 |
| Email already exists | UserService.RegisterUser (called by RegisterDevice) | DB UNIQUE INDEX `users_email_uidx` violation translated to `EmailExists` (5) | 409 — caller retries (the next call recomputes a fresh `azj-NNNN`) |
> **Implementation note** — `RegisterDevice` reuses `UserService.RegisterUser` for the row insert (post-security-audit consolidation, finding F-3). The `users.email` column has a UNIQUE INDEX (`env/db/06_users_email_unique.sql`); concurrent provisioning calls that race on the same serial surface the violation atomically.
---
## Flow F10: OTA Update Check & Publish *(REMOVED — post-cycle-1 revert)*
The `POST /get-update` and `POST /resources/publish` endpoints, the `IResourceUpdateService` / `ResourceUpdateService` / `ResourceColumnEncryption` types, the `Resource` entity, the `resources` table, the `apiUploaderPolicy`, and the `ResourcesConfig.EncryptionMasterKey` field were all removed shortly after AZ-183 shipped.
Reasons:
1. Security audit finding F-1 — `/get-update` was registered with `.RequireAuthorization()` (any authenticated caller) and returned the per-resource decrypted `EncryptionKey` in the response body, defeating the at-rest column encryption.
2. The OTA delivery model is itself a leftover from the installer-shipping era; the target architecture (browser-only SaaS + fTPM-secured Jetsons) does not need it.
The `apiUploaderPolicy` definition was removed from `Program.cs`; the `RoleEnum.ResourceUploader` enum value remains as data (the seed `uploader@azaion.com` user still uses it for negative-auth tests) but is no longer wired to any endpoint.
+346
View File
@@ -473,3 +473,349 @@
**Expected outcome**: HTTP 400 with password length validation error
**Max execution time**: 5s
---
## Cycle 1 Additions (2026-05-13)
The scenarios below were appended during the existing-code cycle 1 Test-Spec Sync (autodev Step 12) for tasks AZ-513, AZ-196, AZ-183, AZ-197. Numbering continues from the legacy IDs above; existing IDs are preserved.
### Cycle 1 Obsoletion Note
The following legacy entries describe behaviour removed by AZ-197 (admin-side hardware-binding cleanup). Their bodies are intentionally left intact to preserve traceability IDs per the cycle-update rule "preserve existing traceability IDs"; they should be treated as obsolete and superseded by FT-N-15 below:
- 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.
See `_docs/03_implementation/batch_06_report.md` for the full AZ-197 implementation rationale and the wire-compat policy decision (drop entirely).
---
### Detection Classes CRUD (AZ-513)
#### FT-P-14: POST /classes Creates Detection Class
**Summary**: ApiAdmin creates a new detection class and the response includes the assigned id.
**Traces to**: AZ-513 AC-1
**Category**: Detection Classes CRUD
**Preconditions**:
- Caller authenticated as ApiAdmin
- `detection_classes` table exists
**Input data**: `{"name":"Tank","shortName":"T","color":"#FF0000","maxSizeM":5.0}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /classes with valid body and ApiAdmin JWT | HTTP 200/201 with body containing assigned `id` and the submitted fields |
**Expected outcome**: HTTP 200 or 201, response body has integer `id` and matches input fields
**Max execution time**: 5s
---
#### FT-P-15: PATCH /classes/{id} Full Body Update
**Summary**: Updating a detection class with a full body replaces the changed fields.
**Traces to**: AZ-513 AC-3
**Category**: Detection Classes CRUD
**Preconditions**:
- A detection class with id `7` exists with `name: "Tank"`
**Input data**: `{"name":"Heavy Tank","shortName":"T","color":"#FF0000","maxSizeM":5.0}` to PATCH /classes/7
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PATCH /classes/7 with full body and ApiAdmin JWT | HTTP 200, response body shows `name: "Heavy Tank"` |
**Expected outcome**: HTTP 200, updated entity reflects the changed field
**Max execution time**: 5s
---
#### FT-P-16: PATCH /classes/{id} Partial Body Update
**Summary**: PATCH with only the changed field updates that field and leaves others intact.
**Traces to**: AZ-513 AC-4
**Category**: Detection Classes CRUD
**Preconditions**:
- A detection class with id `7` exists with `name: "Tank", color: "#FF0000", maxSizeM: 5.0`
**Input data**: `{"color":"#00FF00"}` to PATCH /classes/7
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PATCH /classes/7 with partial body and ApiAdmin JWT | HTTP 200, response body shows `color: "#00FF00"`; other fields unchanged |
**Expected outcome**: HTTP 200, partial-merge semantics confirmed
**Max execution time**: 5s
---
#### FT-P-17: DELETE /classes/{id} Removes Class
**Summary**: ApiAdmin deletes a detection class and it disappears from the DB.
**Traces to**: AZ-513 AC-7
**Category**: Detection Classes CRUD
**Preconditions**:
- A detection class with id `7` exists
**Input data**: DELETE /classes/7
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | DELETE /classes/7 with ApiAdmin JWT | HTTP 200 or 204 |
| 2 | GET the class list (or PATCH the same id) | id 7 no longer present |
**Expected outcome**: HTTP 200/204; class removed from DB
**Max execution time**: 5s
---
#### FT-N-09: POST /classes Without ApiAdmin JWT
**Summary**: POST /classes requires the same `apiAdminPolicy` as `/users`; non-admin / unauthenticated calls are rejected.
**Traces to**: AZ-513 AC-2
**Category**: Detection Classes CRUD
**Preconditions**: None (negative path)
**Input data**: Valid body, but caller has no JWT or a non-ApiAdmin JWT
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /classes without JWT | HTTP 401 |
| 2 | POST /classes with non-ApiAdmin JWT | HTTP 403 |
**Expected outcome**: HTTP 401 (no JWT) or 403 (non-admin)
**Max execution time**: 5s
---
#### FT-N-10: PATCH /classes/{id} Unknown id Returns 404
**Summary**: PATCH against a non-existent id returns 404.
**Traces to**: AZ-513 AC-5
**Category**: Detection Classes CRUD
**Preconditions**: No detection class with id `9999`
**Input data**: PATCH /classes/9999 with any valid body
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PATCH /classes/9999 with ApiAdmin JWT | HTTP 404 |
**Expected outcome**: HTTP 404
**Max execution time**: 5s
---
#### FT-N-11: PATCH /classes/{id} Without ApiAdmin JWT
**Summary**: PATCH /classes/{id} requires `apiAdminPolicy`.
**Traces to**: AZ-513 AC-6
**Category**: Detection Classes CRUD
**Input data**: Any valid body to PATCH /classes/{id}
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PATCH /classes/{id} without JWT | HTTP 401 |
| 2 | PATCH /classes/{id} with non-ApiAdmin JWT | HTTP 403 |
**Expected outcome**: HTTP 401 or 403
**Max execution time**: 5s
---
#### FT-N-12: DELETE /classes/{id} Unknown id Returns 404
**Summary**: DELETE against a non-existent id returns 404 (matching `/users` semantics — non-idempotent).
**Traces to**: AZ-513 AC-8
**Category**: Detection Classes CRUD
**Preconditions**: No detection class with id `9999`
**Input data**: DELETE /classes/9999
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | DELETE /classes/9999 with ApiAdmin JWT | HTTP 404 |
**Expected outcome**: HTTP 404
**Max execution time**: 5s
---
#### FT-N-13: DELETE /classes/{id} Without ApiAdmin JWT
**Summary**: DELETE /classes/{id} requires `apiAdminPolicy`.
**Traces to**: AZ-513 AC-9
**Category**: Detection Classes CRUD
**Input data**: DELETE /classes/{id}
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | DELETE /classes/{id} without JWT | HTTP 401 |
| 2 | DELETE /classes/{id} with non-ApiAdmin JWT | HTTP 403 |
**Expected outcome**: HTTP 401 or 403
**Max execution time**: 5s
---
### Device Auto-Registration (AZ-196)
#### FT-P-18: POST /devices Returns Serial / Email / Password
**Summary**: First call to POST /devices returns the next serial in the `azj-NNNN` sequence with a generated email and 32-char hex password.
**Traces to**: AZ-196 AC-1
**Category**: Device Provisioning
**Preconditions**:
- Caller authenticated as ApiAdmin
- No (or known-prior) CompanionPC users in DB
**Input data**: POST /devices with no body, ApiAdmin JWT
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /devices with ApiAdmin JWT | HTTP 200 with `serial` matching `^azj-\d{4}$`, `email` = `{serial}@azaion.com`, `password` = 32 lowercase hex chars |
**Expected outcome**: HTTP 200, all three fields shaped per spec
**Max execution time**: 5s
---
#### FT-P-19: Sequential Device Serials
**Summary**: Repeated calls to POST /devices yield strictly increasing serial numbers.
**Traces to**: AZ-196 AC-2
**Category**: Device Provisioning
**Preconditions**:
- Most recent CompanionPC user has a known serial `azj-NNNN`
**Input data**: POST /devices twice in succession
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /devices → record serial `S1` | HTTP 200 |
| 2 | POST /devices → record serial `S2` | HTTP 200 |
| 3 | Parse the numeric suffix of both | numeric(S2) == numeric(S1) + 1 |
**Expected outcome**: HTTP 200, suffix increments by exactly 1
**Max execution time**: 5s
---
#### FT-P-20: Returned Device Credentials Can Login
**Summary**: The plaintext password returned by POST /devices succeeds against POST /login (and the persisted hash is therefore correct).
**Traces to**: AZ-196 AC-3, AZ-196 AC-4
**Category**: Device Provisioning
**Preconditions**:
- Caller authenticated as ApiAdmin
**Input data**: Use the response from POST /devices as `{Email, Password}` to POST /login
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /devices with ApiAdmin JWT | HTTP 200, `{Serial, Email, Password}` returned |
| 2 | POST /login with the returned `Email` and `Password` | HTTP 200 with non-empty JWT |
**Expected outcome**: HTTP 200 on login; persisted user has Role=CompanionPC, IsEnabled=true (verified by AdminApi behaviour rather than direct DB inspection)
**Max execution time**: 5s
---
#### FT-N-14: POST /devices Without ApiAdmin JWT
**Summary**: POST /devices requires `apiAdminPolicy`.
**Traces to**: AZ-196 AC-5
**Category**: Device Provisioning
**Input data**: POST /devices
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /devices without JWT | HTTP 401 |
| 2 | POST /devices with non-ApiAdmin JWT | HTTP 403 |
**Expected outcome**: HTTP 401 or 403
**Max execution time**: 5s
---
### Resources OTA Update Check (AZ-183) — REVERTED post-cycle-1
The OTA update check & publish feature shipped in cycle 1 was reverted later the same day after the security audit (finding F-1: `/get-update` disclosed plaintext per-resource encryption keys to any authenticated caller). The OTA delivery model itself was deemed obsolete in the target architecture.
The scenarios `FT-P-21`, `FT-P-22`, `FT-P-23` are retained here as ID placeholders so previously-cited references resolve. Their bodies are intentionally collapsed because the underlying endpoints, service, entity, table, and the e2e test class `ResourceUpdateTests.cs` were all removed. See `_docs/02_document/system-flows.md` (Flow F10) and `_docs/05_security/security_report.md` (finding F-1) for context.
| Removed Test ID | Was tracing | Disposition |
|-----------------|-------------|-------------|
| FT-P-21 | AZ-183 AC-2 | Removed — endpoint and test deleted |
| FT-P-22 | AZ-183 AC-3 | Removed — endpoint and test deleted |
| FT-P-23 | AZ-183 AC-5 | Removed — endpoint and test deleted |
---
### Hardware-Binding Removal (AZ-197)
#### FT-N-15: Hardware Endpoints Removed
**Summary**: The legacy `PUT /users/hardware/set` endpoint and the `POST /resources/check` endpoint have been removed and now return 404.
**Traces to**: AZ-197 AC-2
**Category**: Authorization & Routing
**Preconditions**:
- Updated admin API build (post-AZ-197)
**Input data**: PUT /users/hardware/set and POST /resources/check
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PUT /users/hardware/set with ApiAdmin JWT | HTTP 404 |
| 2 | POST /resources/check with ApiAdmin JWT | HTTP 404 |
**Expected outcome**: HTTP 404 on both routes
**Max execution time**: 5s
Note: AZ-197 AC-1 (resource download works without `Hardware`) is implicitly covered by the existing FT-P-09 / FT-P-10 scenarios once their request bodies are aligned with the new wire shape. AZ-197 AC-3..AC-8 are internal-signature / build-system invariants and are verified at build/CI time, not via a blackbox HTTP scenario.
+22 -51
View File
@@ -1,5 +1,7 @@
# Performance Tests
> **Cycle 1 update (2026-05-13)**: NFT-PERF-02 and NFT-PERF-03 (encrypted resource download, small/large file) were removed because the OTA / encrypted-resource-download endpoints (`POST /resources/get/...`) and the hardware-binding flow they depended on were reverted in cycle 1 (AZ-183 OTA revert, AZ-197 hardware removal). When OTA returns under the new architecture, perf scenarios for it must be re-derived from the new endpoints.
### NFT-PERF-01: Login Endpoint Latency
**Summary**: Login endpoint responds within acceptable latency under normal load.
@@ -7,77 +9,46 @@
**Metric**: Response time (p95)
**Preconditions**:
- System running with seed data
- 10 concurrent users
- System running with seed data (admin user from `e2e/db-init/99_test_seed.sql`)
- 10 concurrent virtual users
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Send 100 login requests (10 concurrent) | Measure p50, p95, p99 response times |
| 1 | 10 VUs send POST /login for 30s | Measure p50, p95, p99 response times |
**Pass criteria**: p95 latency < 500ms
**Duration**: 30 seconds
---
### NFT-PERF-02: Resource Download Latency (Small File)
**Summary**: Encrypted resource download for a small file (1 KB) completes quickly.
**Traces to**: AC-14
**Metric**: Response time including encryption
**Preconditions**:
- 1 KB test file uploaded
- User authenticated with bound hardware
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Send 50 encrypted download requests (5 concurrent) | Measure p50, p95 response times |
**Pass criteria**: p95 latency < 1000ms
**Duration**: 30 seconds
---
### NFT-PERF-03: Resource Download Latency (Large File)
**Summary**: Encrypted resource download for a larger file (50 MB) completes within limits.
**Traces to**: AC-13, AC-14
**Metric**: Response time including encryption + transfer
**Preconditions**:
- 50 MB test file uploaded
- User authenticated with bound hardware
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Send 5 sequential encrypted download requests | Measure p50, p95 response times |
**Pass criteria**: p95 latency < 30000ms (30s)
**Duration**: 3 minutes
---
### NFT-PERF-04: User List Endpoint Under Load
**Summary**: User list endpoint responds within limits when DB has many users.
**Traces to**: AC-9
**Metric**: Response time
**Summary**: `GET /users` responds within limits when DB has many users.
**Traces to**: AC-11
> **Note**: this scenario originally referenced AC-9. Post-cycle-1, AC-9 is "Registration rejects duplicate email". The user-listing criterion is AC-11 (filter support). The thresholds below verify the listing path under volume; the filter semantics are covered by functional tests.
**Metric**: Response time (p95)
**Preconditions**:
- 500 users in database
- Caller is ApiAdmin
- Database seeded with 500 users (perf seed inserts dummy rows alongside the functional seed; see `scripts/run-performance-tests.sh`)
- Caller is `admin@azaion.com` (ApiAdmin)
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Send 50 GET /users requests (10 concurrent) | Measure p50, p95 response times |
| 1 | 10 VUs send GET /users for 30s, sharing one cached JWT | Measure p50, p95, p99 response times |
**Pass criteria**: p95 latency < 1000ms
**Duration**: 30 seconds
---
## Runner
Both scenarios are implemented in `scripts/perf-scenarios.js` (k6, JS) and orchestrated by `scripts/run-performance-tests.sh`. The runner spins up the SUT via `docker-compose.test.yml`, seeds 500 perf users into `test-db`, executes k6, captures the JSON summary to `e2e/test-results/perf-summary.json`, and tears down.
To run locally: `./scripts/run-performance-tests.sh`. Requires `k6` (`brew install k6`) and Docker.
+68 -2
View File
@@ -41,9 +41,10 @@
| Category | Total Items | Covered | Not Covered | Coverage % |
|----------|-----------|---------|-------------|-----------|
| Acceptance Criteria | 19 | 19 | 0 | 100% |
| Acceptance Criteria (baseline) | 19 | 19 | 0 | 100% |
| Acceptance Criteria (cycle 1) | 24 | 24 | 0 | 100% |
| Restrictions | 8 | 5 | 3 | 63% |
| **Total** | **27** | **24** | **3** | **89%** |
| **Total** | **51** | **48** | **3** | **94%** |
## Uncovered Items Analysis
@@ -52,3 +53,68 @@
| RESTRICT-HW-01 (ARM64) | Tests run on x64 dev/CI host; cross-architecture testing requires ARM hardware | Low — .NET runtime handles arch differences; no arch-specific code in application | CI builds ARM64 image; manual smoke test on target device |
| RESTRICT-ENV-02 (CORS) | CORS is enforced by browsers, not by server-to-server HTTP calls | Low — CORS policy is declarative in Program.cs | Visual inspection of CORS configuration in code |
| RESTRICT-OP-01 (Logging) | Log output format/content verification adds complexity without proportional value | Low — Serilog configuration is declarative | Code review of Serilog setup |
## Cycle 1 Additions (2026-05-13) — AZ-513, AZ-196, AZ-183, AZ-197
Appended during the existing-code cycle 1 Test-Spec Sync (autodev Step 12). Cycle 1 ACs are namespaced by their tracker ID to avoid colliding with the baseline AC-1..AC-19 numbering above.
### AZ-513 — Detection Classes CRUD
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-513 AC-1 | POST /classes creates a class | FT-P-14 | Covered |
| AZ-513 AC-2 | POST /classes requires ApiAdmin authorization | FT-N-09 | Covered |
| AZ-513 AC-3 | PATCH /classes/{id} updates an existing class (full body) | FT-P-15 | Covered |
| AZ-513 AC-4 | PATCH /classes/{id} accepts partial body (partial-merge) | FT-P-16 | Covered |
| AZ-513 AC-5 | PATCH /classes/{id} returns 404 for unknown id | FT-N-10 | Covered |
| AZ-513 AC-6 | PATCH /classes/{id} requires ApiAdmin authorization | FT-N-11 | Covered |
| AZ-513 AC-7 | DELETE /classes/{id} removes a class | FT-P-17 | Covered |
| AZ-513 AC-8 | DELETE /classes/{id} returns 404 for unknown id | FT-N-12 | Covered |
| AZ-513 AC-9 | DELETE /classes/{id} requires ApiAdmin authorization | FT-N-13 | Covered |
| AZ-513 AC-10 | UI add/delete/edit affordances work end-to-end | — | Cross-workspace (ui/ e2e harness) — out of scope for this workspace |
### AZ-196 — Device Auto-Registration
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-196 AC-1 | First device gets serial azj-0000 (shape: serial / email / 32-hex password) | FT-P-18 | Covered |
| AZ-196 AC-2 | Sequential numbering on subsequent calls | FT-P-19 | Covered |
| AZ-196 AC-3 | Persisted user has Role=CompanionPC, IsEnabled=true | FT-P-20 | Covered (verified via successful login → role-gated behaviour) |
| AZ-196 AC-4 | Returned plaintext password is hashed (SHA-384) in DB, not stored plaintext | FT-P-20 | Covered (verified via successful login round-trip) |
| AZ-196 AC-5 | Requires ApiAdmin authorization | FT-N-14 | Covered |
### AZ-183 — Resources OTA Update Check (REVERTED post-cycle-1)
The OTA Update Check & Publish feature shipped in cycle 1 was reverted later the same day after the security audit (finding F-1: `/get-update` disclosed plaintext per-resource encryption keys to any authenticated caller; the OTA delivery model itself was deemed obsolete in the target architecture). The endpoints, service, entity, table, request DTOs, response DTO, cache key, master-key config field, and the e2e test class `ResourceUpdateTests` were all removed.
| AC ID | Acceptance Criterion | Test IDs | Status |
|-------|---------------------|----------|--------|
| AZ-183 AC-1 | Resources table created with required columns | — | **Reverted** — table dropped from migration set (`env/db/05_resources.sql` deleted) |
| AZ-183 AC-2 | POST /get-update returns newer resources | ~~FT-P-21~~ | **Reverted** — endpoint and test deleted |
| AZ-183 AC-3 | POST /get-update returns empty when device already current | ~~FT-P-22~~ | **Reverted** — endpoint and test deleted |
| AZ-183 AC-4 | Memory cache avoids DB pressure under 2000-device polling | — | **Reverted** — cache key removed |
| AZ-183 AC-5 | Cache invalidated on CI/CD publish | ~~FT-P-23~~ | **Reverted** — endpoint and test deleted |
### AZ-197 — Hardware-Binding Removal
| AC ID | Acceptance Criterion | Test IDs | Coverage |
|-------|---------------------|----------|----------|
| AZ-197 AC-1 | Resource download works without `Hardware` field | FT-P-09 / FT-P-10 (legacy bodies retained; wire shape now omits the field) | Covered (e2e `ResourceTests` updated by AZ-197 batch 6) |
| AZ-197 AC-2 | `PUT /users/hardware/set` and `POST /resources/check` return 404 | FT-N-15 | Covered |
| AZ-197 AC-3 | `Security.GetApiEncryptionKey` signature simplified to (email, password) | — | Internal signature — covered by `Azaion.Test/SecurityTest` unit tests, not blackbox |
| AZ-197 AC-4 | `HardwareBindingTests` removed; no remaining test asserts code 40 / hardware-hash binding | — | Build/CI invariant — verified by test-suite enumeration |
| AZ-197 AC-5 | Resource calls in remaining tests do not send `Hardware` | — | Build/CI invariant — verified by source review during AZ-197 batch 6 |
| AZ-197 AC-6 | `ExceptionEnum` no longer carries `HardwareIdMismatch` / `BadHardware` | — | Build/CI invariant — verified by enum read |
| AZ-197 AC-7 | `dotnet build` is clean (no new warnings) | — | Build invariant |
| AZ-197 AC-8 | Test suite passes (excluding deleted `HardwareBindingTests`) | All e2e tests + `Azaion.Test` | Covered by Step 11 Run Tests (48/48 e2e + 2/2 unit, 2026-05-13) |
### Obsoleted Baseline Entries (superseded by AZ-197)
The matrix rows below are kept for ID stability but no longer reflect production behaviour. They are superseded by the AZ-197 entries above and by FT-N-15 in `blackbox-tests.md`. Do NOT regenerate or delete these in cycle-update mode — wait for a full `/test-spec` rerun.
| Legacy Matrix Row | Status |
|-------------------|--------|
| AC-10 (First hardware check stores) | Obsoleted by AZ-197 — endpoint removed |
| 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 |