mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 20:31:08 +00:00
[AZ-197] Remove hardware ID binding from resource flow
Sealed-Jetson + SaaS architecture eliminates the credential-reuse-across-
machines threat that motivated hardware fingerprint binding. The binding's
only remaining effect was a real production failure mode on legitimate
hardware events.
Production:
- Drop PUT /users/hardware/set and POST /resources/check.
- Simplify POST /resources/get/{dataFolder?} (no Hardware field).
- Remove CheckHardwareHash, UpdateHardware, Security.GetHWHash.
- GetApiEncryptionKey signature: (email, password) — no hardwareHash.
- Drop SetHWRequest DTO and Hardware property from GetResourceRequest.
- Remove HardwareIdMismatch (40) and BadHardware (45) ExceptionEnum
entries; numeric codes left as a gap, not for reuse.
Wire-compat policy: drop entirely (no Loader; no in-flight legacy
clients). Stale callers will see 404s, which is the right loud failure.
Tombstones:
- User.Hardware DB column kept (nullable, unused) — separate cleanup
ticket for the migration per workspace "no rename without confirmation".
- User.LastLogin is now never written by app code (only writer was inside
the deleted CheckHardwareHash); flagged in batch_06_review for a future
ticket.
Tests:
- Delete e2e HardwareBindingTests (165 lines) and Azaion.Test
UserServiceTest (sole test was CheckHardwareHashTest).
- Drop Hardware payloads + /resources/check preconditions from e2e
ResourceTests, SecurityTests, ResilienceTests; drop hardwareId arg
from Azaion.Test SecurityTest.
- Add SecurityTests.Hardware_endpoints_are_removed_AZ_197 (AC-2 regression
asserting both removed routes return 404).
Docs:
- architecture.md: System Context note, ADR-003 new key formula, ADR-004
retired with rationale.
- diagrams/flows/flow_hardware_check.md: tombstoned.
Also archives the four batch-1+batch-2 task files into _docs/02_tasks/done/
(file moves were missed by the batch_05 commit).
Code review: PASS — see _docs/03_implementation/reviews/batch_06_review.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,61 +0,0 @@
|
||||
# Resources Table & Update Check API
|
||||
|
||||
**Task**: AZ-183_resources_table_update_api
|
||||
**Name**: Resources Table & Update Check API
|
||||
**Description**: Add Resources table to admin API PostgreSQL DB and implement POST /get-update endpoint for fleet OTA updates
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: None
|
||||
**Component**: Admin API
|
||||
**Tracker**: AZ-183
|
||||
**Epic**: AZ-181
|
||||
|
||||
## Problem
|
||||
|
||||
The fleet update system needs a server-side component that tracks published artifact versions and tells devices what needs updating. CI/CD publishes encrypted artifacts to CDN; the server must store metadata (version, URL, hash, encryption key) and serve it to devices on request.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Resources table stores per-artifact metadata populated by CI/CD
|
||||
- Devices call POST /get-update with their current versions and get back only what's newer
|
||||
- Server-side memory cache handles 2000+ devices polling every 5 minutes without DB pressure
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- Resources table migration (resource_name, dev_stage, architecture, version, cdn_url, sha256, encryption_key, size_bytes, created_at)
|
||||
- POST /get-update endpoint: accepts device's current versions + architecture + dev_stage, returns only newer resources
|
||||
- Server-side memory cache invalidated on CI/CD publish
|
||||
- Internal endpoint or direct DB write for CI/CD to publish new resource versions
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Resources table created**
|
||||
Given the admin API database
|
||||
When the migration runs
|
||||
Then the Resources table exists with all required columns
|
||||
|
||||
**AC-2: Update check returns newer resources**
|
||||
Given Resources table has annotations version 2026-04-13
|
||||
When device sends POST /get-update with annotations version 2026-02-25
|
||||
Then response includes annotations with version, cdn_url, sha256, encryption_key, size_bytes
|
||||
|
||||
**AC-3: Current device gets empty response**
|
||||
Given device already has the latest version of all resources
|
||||
When POST /get-update is called
|
||||
Then response is an empty array
|
||||
|
||||
**AC-4: Memory cache avoids repeated DB queries**
|
||||
Given 2000 devices polling every 5 minutes
|
||||
When POST /get-update is called repeatedly
|
||||
Then the latest versions are served from memory cache, not from DB on every request
|
||||
|
||||
**AC-5: Cache invalidated on publish**
|
||||
Given a new resource version is published via CI/CD
|
||||
When the publish endpoint/function completes
|
||||
Then the next POST /get-update call returns the new version
|
||||
|
||||
## Constraints
|
||||
|
||||
- Must integrate with existing admin API (linq2db + PostgreSQL)
|
||||
- encryption_key column must be stored securely (encrypted at rest in DB or via application-level encryption)
|
||||
- Response must include encryption_key only over HTTPS with valid JWT
|
||||
@@ -1,95 +0,0 @@
|
||||
# Register Device Endpoint
|
||||
|
||||
**Task**: AZ-196_register_device_endpoint
|
||||
**Name**: POST /devices endpoint for auto device registration
|
||||
**Description**: Add POST /devices endpoint to admin API that auto-generates device serial, email, and password for CompanionPC users
|
||||
**Complexity**: 2 points
|
||||
**Dependencies**: None
|
||||
**Component**: Admin API
|
||||
**Tracker**: AZ-196
|
||||
**Epic**: AZ-181
|
||||
|
||||
## Problem
|
||||
|
||||
During Jetson manufacturing, each device needs a unique CompanionPC identity (serial, email, password). Currently the provisioning script generates the email client-side and calls POST /users. The serial/email format should be server-controlled so the admin API is the single source of truth for device numbering.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Single POST /devices endpoint that requires no request body
|
||||
- Server auto-assigns the next sequential serial (azj-0000, azj-0001, ...)
|
||||
- Returns plaintext credentials so the provisioning script can embed them in device.conf
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- `RegisterDeviceResponse` DTO in `Azaion.Common/Requests/` with `Serial`, `Email`, `Password` fields
|
||||
- `RegisterDevice` method on `IUserService` / `UserService`
|
||||
- `POST /devices` endpoint in `Program.cs` with `RequireAuthorization(apiAdminPolicy)`
|
||||
- Sequential serial assignment based on most recent CompanionPC user
|
||||
|
||||
### Excluded
|
||||
|
||||
- Changes to the provisioning shell script (handled in loader repo)
|
||||
- Removing old POST /users endpoint (still used for non-device users)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Serial number logic
|
||||
|
||||
Email format: `azj-NNNN@azaion.com` where NNNN is zero-padded to 4 digits.
|
||||
|
||||
Constants at the top of `UserService`:
|
||||
|
||||
```csharp
|
||||
private const int SerialNumberStart = 4;
|
||||
private const int SerialNumberLength = 4;
|
||||
```
|
||||
|
||||
`RegisterDevice` implementation:
|
||||
|
||||
1. Query the single most recent CompanionPC user: `WHERE role = 'CompanionPC' ORDER BY created_at DESC LIMIT 1`
|
||||
2. If none found, next number is 0000; otherwise extract via `Substring(SerialNumberStart, SerialNumberLength)`, parse, increment
|
||||
3. Generate email `azj-{number:D4}@azaion.com`
|
||||
4. Generate random 32-char hex password: `Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLower()`
|
||||
5. Insert User with `Role = CompanionPC`, `IsEnabled = true`, password hashed via `ToHash()`
|
||||
6. Return `RegisterDeviceResponse { Serial, Email, Password (plaintext) }`
|
||||
|
||||
### Endpoint
|
||||
|
||||
```csharp
|
||||
app.MapPost("/devices",
|
||||
async (IUserService userService, CancellationToken cancellationToken)
|
||||
=> await userService.RegisterDevice(cancellationToken))
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Creates a new device");
|
||||
```
|
||||
|
||||
No request body required.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: First device gets serial azj-0000**
|
||||
Given no CompanionPC users exist in the database
|
||||
When POST /devices is called with a valid ApiAdmin JWT
|
||||
Then the response contains `serial: "azj-0000"`, `email: "azj-0000@azaion.com"`, and a 32-char hex password
|
||||
|
||||
**AC-2: Sequential numbering**
|
||||
Given azj-0000 already exists
|
||||
When POST /devices is called again
|
||||
Then the response contains `serial: "azj-0001"`
|
||||
|
||||
**AC-3: User persisted with correct role**
|
||||
Given POST /devices returned successfully
|
||||
When the users table is queried
|
||||
Then a user exists with the returned email, Role=CompanionPC, IsEnabled=true
|
||||
|
||||
**AC-4: Password is hashed in DB**
|
||||
Given POST /devices returned a plaintext password
|
||||
When the users table is inspected
|
||||
Then PasswordHash contains the SHA-384 hash of the plaintext password, not the plaintext itself
|
||||
|
||||
**AC-5: Requires ApiAdmin authorization**
|
||||
Given a request without a JWT or with a non-ApiAdmin JWT
|
||||
When POST /devices is called
|
||||
Then 401 or 403 is returned
|
||||
@@ -1,70 +0,0 @@
|
||||
# Remove Hardware ID Binding
|
||||
|
||||
**Task**: AZ-197_remove_hardware_id
|
||||
**Name**: Remove hardware ID binding from resource flow
|
||||
**Description**: Remove CheckHardwareHash, UpdateHardware, HardwareService and simplify API encryption key derivation. Sealed Jetsons eliminate the credential-reuse threat this was protecting against.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: None
|
||||
**Component**: Admin API, Loader
|
||||
**Tracker**: AZ-197
|
||||
**Epic**: AZ-181
|
||||
|
||||
## Problem
|
||||
|
||||
The `Hardware` field on `User` and the `CheckHardwareHash` flow were designed to bind a user account to a specific physical machine, preventing credential reuse across machines when users had desktop installers. With sealed Jetsons (secure boot, fTPM, no user filesystem access, no installers distributed), this threat no longer exists. The hardware binding adds unnecessary complexity and failure modes (HardwareIdMismatch on drive replacement, etc.).
|
||||
|
||||
## Outcome
|
||||
|
||||
- Simpler resource download flow without hardware fingerprint requirement
|
||||
- Simpler API encryption key derivation (email + password only)
|
||||
- Removal of dead code paths related to hardware binding
|
||||
- Fewer failure modes in production
|
||||
|
||||
## Scope
|
||||
|
||||
### Admin API changes
|
||||
|
||||
- Remove `CheckHardwareHash` and `UpdateHardware` from `IUserService` / `UserService`
|
||||
- Remove `PUT /users/hardware/set` endpoint from `Program.cs`
|
||||
- Simplify `POST /resources/get/{dataFolder}`: remove `request.Hardware` parameter, derive encryption key without hardware hash
|
||||
- Simplify `POST /resources/check`: remove hardware check entirely (or remove the endpoint if unused)
|
||||
- Update `Security.GetApiEncryptionKey` to not require `hardwareHash` parameter
|
||||
- Remove or deprecate `Security.GetHWHash`
|
||||
- Leave `User.Hardware` column nullable in DB (no migration needed, just stop writing/reading it)
|
||||
- Remove `SetHWRequest` DTO
|
||||
- Remove `HardwareIdMismatch` and `BadHardware` from `ExceptionEnum`
|
||||
|
||||
### Loader client changes
|
||||
|
||||
- Remove `HardwareService` class (`hardware_service.pyx`, `hardware_service.pxd`)
|
||||
- Update `api_client.pyx` `load_bytes`: stop gathering hardware info, stop sending `hardware` field in resource request
|
||||
- Update `security.pyx` `get_api_encryption_key`: remove `hardware_hash` parameter
|
||||
- Update `security_provider.py`, `tpm_security_provider.py`, `legacy_security_provider.py`: remove `get_hw_hash` and update `get_api_encryption_key` signature
|
||||
- Update `GetResourceRequest` validator to not require Hardware field
|
||||
|
||||
### Excluded
|
||||
|
||||
- Database migration to drop the `hardware` column (leave nullable, stop using it)
|
||||
- Changes to user registration or login flow
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Resource download works without hardware**
|
||||
Given a provisioned device with valid email and password
|
||||
When the loader calls POST /resources/get without a hardware field
|
||||
Then the resource is returned and can be decrypted using email + password only
|
||||
|
||||
**AC-2: No hardware endpoints remain**
|
||||
Given the updated admin API
|
||||
When PUT /users/hardware/set is called
|
||||
Then 404 is returned
|
||||
|
||||
**AC-3: Encryption key derivation is simplified**
|
||||
Given the updated Security class
|
||||
When GetApiEncryptionKey is called
|
||||
Then it derives the key from email + password only (no hardware hash)
|
||||
|
||||
**AC-4: HardwareService removed from loader**
|
||||
Given the updated loader codebase
|
||||
When the build is run
|
||||
Then it compiles without hardware_service.pyx/pxd
|
||||
@@ -1,140 +0,0 @@
|
||||
# Detection Classes CRUD Routes
|
||||
|
||||
**Task**: AZ-513_classes_crud_routes
|
||||
**Name**: POST + PATCH + DELETE /classes routes for detection-class CRUD
|
||||
**Description**: Add the three missing /classes endpoints to the admin API. The UI already calls POST and DELETE today (broken end-to-end — pre-existing bug); PATCH is required by AZ-512 (UI workspace) for the in-place edit affordance.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: None on the admin/ side. **Unblocks**: AZ-512 in the UI workspace.
|
||||
**Component**: Admin API
|
||||
**Tracker**: AZ-513
|
||||
**Epic**: AZ-509
|
||||
**Filed by**: cross-workspace prerequisite from `ui/` autodev cycle 3 batch 15 (BLOCKING gate). See `ui/_docs/_process_leftovers/2026-05-13_az-512-admin-classes-prereq.md`.
|
||||
|
||||
## Problem
|
||||
|
||||
The UI workspace (`ui/src/features/admin/AdminPage.tsx`) calls three `/classes` endpoints today, but the admin/ service does not expose any of them — verified 2026-05-13 by grepping `Azaion.AdminApi/Program.cs` for `MapPost|MapPatch|MapDelete` against `classes` (zero matches):
|
||||
|
||||
- `POST /api/admin/classes` — UI calls this to add a new detection class (`handleAddClass`). Today: 404. **Pre-existing bug** — the "Add" button on the Detection Classes table has been broken end-to-end against the live admin/ service.
|
||||
- `DELETE /api/admin/classes/{id}` — UI calls this to delete a class (`handleDeleteClass`). Today: 404. **Pre-existing bug** — the row delete affordance has been broken end-to-end too.
|
||||
- `PATCH /api/admin/classes/{id}` — UI does NOT call this today; AZ-512 (UI workspace) needs to call it to deliver the in-place edit affordance promised by Architecture Vision principle P12 ("the Admin Detection Classes table supports the full CRUD surface, not add+delete only"). Currently the route does not exist.
|
||||
|
||||
The UI's `endpoints.admin.classes()` and `endpoints.admin.class(id)` builders pin the URLs (`ui/src/api/endpoints.ts`) and are wire-contract tested in `ui/src/api/endpoints.test.ts`. nginx routes `/api/admin/` → `http://admin:8080/`, so inside this service the paths are `/classes` and `/classes/{id}`.
|
||||
|
||||
## Outcome
|
||||
|
||||
- POST /classes creates a new detection class.
|
||||
- PATCH /classes/{id} updates an existing detection class (full or partial body — both accepted).
|
||||
- DELETE /classes/{id} deletes a detection class.
|
||||
- All three guarded by the same auth middleware as `/users` (`apiAdminPolicy`).
|
||||
- After this lands, the UI's existing add/delete affordances start working end-to-end, and AZ-512 (UI workspace) un-blocks for its in-place edit form.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- `IDetectionClassService` interface + `DetectionClassService` implementation in `Azaion.Services/`, injected via DI alongside `IUserService` (the existing pattern in `Program.cs`).
|
||||
- DTOs in `Azaion.Common/Requests/`: `CreateDetectionClassRequest`, `UpdateDetectionClassRequest`. Field set: `name`, `shortName`, `color`, `maxSizeM` (and `photoMode` if the live backend already supports it for ADD — verify via the existing read-path service before adding).
|
||||
- 3 minimal-API handlers in `Program.cs` mounted under the existing `/classes` prefix, each calling `RequireAuthorization(apiAdminPolicy)` per the `/users` precedent.
|
||||
- DB schema work: verify `detection_classes` table exists on the admin/ DB. If it does not, add a migration following the existing `Azaion.Test`/`docker.test` schema-init pattern.
|
||||
- Tests in `Azaion.Test` mirroring the existing user-CRUD test shape.
|
||||
|
||||
### Excluded
|
||||
|
||||
- UI-side changes — handled by AZ-512 in the UI workspace once this lands.
|
||||
- Read path `GET /classes` — UI today already reads classes successfully. Verify which service serves the read (likely `annotations/`); if it is NOT `admin/`, do NOT migrate the read path to `admin/` as part of this ticket. Leave the read wire shape alone — the UI will keep calling whichever service answers today.
|
||||
- Bulk operations (no batch POST/PATCH/DELETE) — out of scope.
|
||||
- Soft-delete vs hard-delete policy — match whatever `/users` does today (presumably hard-delete).
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Endpoints (illustrative — match the `/users` pattern in `Program.cs`)
|
||||
|
||||
```csharp
|
||||
app.MapPost("/classes",
|
||||
async (IDetectionClassService svc, CreateDetectionClassRequest req, CancellationToken ct)
|
||||
=> await svc.Create(req, ct))
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Creates a new detection class");
|
||||
|
||||
app.MapPatch("/classes/{id}",
|
||||
async (IDetectionClassService svc, int id, UpdateDetectionClassRequest req, CancellationToken ct)
|
||||
=> await svc.Update(id, req, ct))
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Updates an existing detection class (partial-merge accepted)");
|
||||
|
||||
app.MapDelete("/classes/{id}",
|
||||
async (IDetectionClassService svc, int id, CancellationToken ct)
|
||||
=> await svc.Delete(id, ct))
|
||||
.RequireAuthorization(apiAdminPolicy)
|
||||
.WithSummary("Deletes a detection class");
|
||||
```
|
||||
|
||||
### PATCH semantics
|
||||
|
||||
The UI sends the complete body on edit (per AZ-512 spec Risk 2 mitigation — "send all fields on PATCH so the implementer doesn't have to choose between full-replace and partial-merge"). Implementing PATCH as a partial-merge over the loaded entity is therefore safe: missing fields keep their existing value, present fields overwrite. Either full-replace or partial-merge works for the UI; partial-merge is the more conservative choice.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: POST /classes creates a class**
|
||||
Given a valid ApiAdmin JWT and a body `{ "name": "Tank", "shortName": "T", "color": "#FF0000", "maxSizeM": 5.0 }`
|
||||
When POST /classes is called
|
||||
Then 200 or 201 is returned with the created class object including its assigned id
|
||||
|
||||
**AC-2: POST /classes requires ApiAdmin authorization**
|
||||
Given a request without a JWT or with a non-ApiAdmin JWT
|
||||
When POST /classes is called
|
||||
Then 401 or 403 is returned
|
||||
|
||||
**AC-3: PATCH /classes/{id} updates an existing class**
|
||||
Given a class with id 7 exists with `name: "Tank"`
|
||||
When PATCH /classes/7 is called with body `{ "name": "Heavy Tank", "shortName": "T", "color": "#FF0000", "maxSizeM": 5.0 }`
|
||||
Then 200 is returned with the updated class object reflecting `name: "Heavy Tank"`
|
||||
|
||||
**AC-4: PATCH /classes/{id} accepts partial body**
|
||||
Given a class with id 7 exists with `name: "Tank", color: "#FF0000", maxSizeM: 5.0`
|
||||
When PATCH /classes/7 is called with body `{ "color": "#00FF00" }` (only the changed field)
|
||||
Then 200 is returned with `color: "#00FF00"` and the other fields unchanged
|
||||
|
||||
**AC-5: PATCH /classes/{id} returns 404 for unknown id**
|
||||
Given no class with id 9999 exists
|
||||
When PATCH /classes/9999 is called
|
||||
Then 404 is returned
|
||||
|
||||
**AC-6: PATCH /classes/{id} requires ApiAdmin authorization**
|
||||
Given a request without a JWT or with a non-ApiAdmin JWT
|
||||
When PATCH /classes/{id} is called
|
||||
Then 401 or 403 is returned
|
||||
|
||||
**AC-7: DELETE /classes/{id} removes a class**
|
||||
Given a class with id 7 exists
|
||||
When DELETE /classes/7 is called
|
||||
Then 200 or 204 is returned and the class is gone from the DB
|
||||
|
||||
**AC-8: DELETE /classes/{id} returns 404 for unknown id**
|
||||
Given no class with id 9999 exists
|
||||
When DELETE /classes/9999 is called
|
||||
Then 404 is returned (or 200/204 if idempotent-delete is the established pattern in this service — match `/users` behavior)
|
||||
|
||||
**AC-9: DELETE /classes/{id} requires ApiAdmin authorization**
|
||||
Given a request without a JWT or with a non-ApiAdmin JWT
|
||||
When DELETE /classes/{id} is called
|
||||
Then 401 or 403 is returned
|
||||
|
||||
**AC-10: After this ticket lands, UI add/delete affordances work end-to-end**
|
||||
Given `ui/src/features/admin/AdminPage.tsx` is deployed against this admin/ build
|
||||
When the user clicks the existing "Add Class" button or the row delete button on the Detection Classes table
|
||||
Then the operation succeeds against the live admin/ service (no 404)
|
||||
(This AC is verified by the UI workspace's e2e harness in `ui/e2e/`, not by tests in this workspace. Cross-workspace coordination point: notify the UI team via the AZ-509 epic when this ships so they can re-attempt AZ-512 batch 15.)
|
||||
|
||||
## Cross-workspace notes
|
||||
|
||||
This ticket exists because of a BLOCKING gate hit during the UI workspace's autodev cycle 3. Full context:
|
||||
|
||||
- UI leftover record: `ui/_docs/_process_leftovers/2026-05-13_az-512-admin-classes-prereq.md`
|
||||
- UI task spec (parked in backlog until this ships): `ui/_docs/02_tasks/backlog/AZ-512_admin_edit_detection_class.md`
|
||||
- UI epic linkage: AZ-509 (Cycle 3 — Auth bootstrap fix + classColors carve-out + admin class edit). AZ-513 is linked under AZ-509 because that is the epic the prerequisite unblocks.
|
||||
|
||||
When this ticket ships, the UI workspace's leftovers replay step (next `/autodev` invocation) will:
|
||||
1. Re-run the verification grep against `Azaion.AdminApi/Program.cs`.
|
||||
2. If routes exist → move `ui/_docs/02_tasks/backlog/AZ-512_*.md` back to `todo/` and re-attempt batch 15.
|
||||
3. If routes still missing → leave the UI leftover as-is, surface to the user.
|
||||
Reference in New Issue
Block a user