mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 14:11:10 +00:00
a77b3f8a59
Refreshes _docs/02_document/ to reflect the cycle-2 auth-modernization
+ CMMC hardening landings (AZ-531..AZ-538). Authoritative source for
the ripple set is ripple_log_cycle2.md.
Covered:
- architecture.md (section 1 rewritten, ADRs 6-9 added)
- data_model.md (sessions, audit_events, user columns, migrations)
- system-flows.md (F1 rewritten; F11-F17 added; F2/F7/F9 minor)
- module-layout.md (cycle-2 sub-component table)
- diagrams/flows/flow_login.md (dual-token + MFA)
- components/{01_data_layer,03_auth_and_security,05_admin_api}
- modules/ (12 new, 8 modified — full Argon2id/ES256/MFA/refresh
/mission/session/audit/jwks rollup)
- tests/{blackbox,security,traceability-matrix}
Step 13 (Update Docs) output for cycle 2.
Co-authored-by: Cursor <cursoragent@cursor.com>
402 lines
18 KiB
Markdown
402 lines
18 KiB
Markdown
# Security Tests
|
||
|
||
### NFT-SEC-01: Unauthenticated Access to Protected Endpoints
|
||
|
||
**Summary**: All protected endpoints reject requests without JWT token.
|
||
**Traces to**: AC-18
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | GET /users (no JWT) | HTTP 401 |
|
||
| 2 | POST /resources/{folder} upload (no JWT) | HTTP 401 |
|
||
| 3 | GET /resources/list/{folder} (no JWT) | HTTP 401 |
|
||
| 4 | PUT /users/{email}/set-role/{role} (no JWT) | HTTP 401 |
|
||
| 5 | DELETE /users/{email} (no JWT) | HTTP 401 |
|
||
| 6 | POST /classes (no JWT) | HTTP 401 |
|
||
|
||
**Pass criteria**: All remaining protected endpoints return HTTP 401 for unauthenticated requests.
|
||
|
||
> Earlier revisions of this scenario also covered `POST /resources/get`, `POST /resources/check`, and `GET /resources/get-installer`. Those endpoints were removed (AZ-197 / cycle 2) and now return 404 — see FT-N-15 (AZ-197 routes) and FT-N-16 (cycle-2 routes) in `blackbox-tests.md`.
|
||
|
||
---
|
||
|
||
### NFT-SEC-02: Non-Admin Access to Admin Endpoints
|
||
|
||
**Summary**: Non-ApiAdmin users cannot access admin-only endpoints.
|
||
**Traces to**: AC-9
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | Login as Operator role user | HTTP 200, JWT token |
|
||
| 2 | POST /users (register) with Operator JWT | HTTP 403 |
|
||
| 3 | PUT /users/role with Operator JWT | HTTP 403 |
|
||
| 4 | PUT /users/enable with Operator JWT | HTTP 403 |
|
||
| 5 | DELETE /users with Operator JWT | HTTP 403 |
|
||
|
||
**Pass criteria**: All admin endpoints return HTTP 403 for non-admin users
|
||
|
||
---
|
||
|
||
### NFT-SEC-03: Password Not Returned in User List
|
||
|
||
**Summary**: User list endpoint does not expose password hashes.
|
||
**Traces to**: AC-17
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | GET /users with ApiAdmin JWT | HTTP 200, JSON array |
|
||
| 2 | Inspect each user object in response | No `passwordHash` or `password` field present |
|
||
|
||
**Pass criteria**: Password hash is never included in API responses
|
||
|
||
---
|
||
|
||
### NFT-SEC-04: Expired JWT Token Rejection
|
||
|
||
**Summary**: Expired JWT tokens are rejected.
|
||
**Traces to**: AC-4, AC-18
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | Craft a JWT with `exp` set to past timestamp (same signing key) | Token string |
|
||
| 2 | GET /users with expired JWT | HTTP 401 |
|
||
|
||
**Pass criteria**: Expired token returns HTTP 401
|
||
|
||
---
|
||
|
||
### NFT-SEC-05: Encryption Key Uniqueness — OBSOLETE (cycle 2, 2026-05-14)
|
||
|
||
The `POST /resources/get/{dataFolder?}` endpoint that this test exercised was removed along with `Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo` and `ResourcesService.GetEncryptedResource`. Per-user resource encryption is no longer part of the system. ID retained for traceability stability; do not regenerate the spec body until a full `/test-spec` rerun.
|
||
|
||
---
|
||
|
||
### NFT-SEC-06: Disabled User Cannot Login
|
||
|
||
**Summary**: A disabled user account cannot authenticate.
|
||
**Traces to**: AC-9
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | Register user, disable via PUT /users/enable | HTTP 200 |
|
||
| 2 | Attempt POST /login with disabled user credentials | HTTP 409 or HTTP 403 |
|
||
|
||
**Pass criteria**: Disabled user cannot obtain a JWT token
|
||
|
||
---
|
||
|
||
## Cycle 2 Additions (2026-05-14) — Auth Modernization (AZ-529 + AZ-530)
|
||
|
||
The scenarios below were appended during the existing-code cycle 2 Test-Spec Sync (autodev Step 12) for the security-only / cryptography-invariant ACs in cycle 2. Functional flows live in `blackbox-tests.md` under the matching task. Numbering continues from NFT-SEC-06.
|
||
|
||
### NFT-SEC-07: New User Hashes Use Argon2id (AZ-536)
|
||
|
||
**Summary**: A freshly-registered user's `password_hash` is in Argon2id PHC format with parameters at or above the configured floor.
|
||
**Traces to**: AZ-536 AC-1
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | POST /users (ApiAdmin JWT) registering `freshuser@azaion.com` with a known password | HTTP 200 |
|
||
| 2 | Read `users.password_hash` for `freshuser@azaion.com` directly from Postgres | Value starts with `$argon2id$v=19$m=` |
|
||
| 3 | Parse the PHC string parameters | `m ≥ 65536`, `t ≥ 3`, `p ≥ 1` |
|
||
|
||
**Pass criteria**: All new users land in Argon2id PHC format with at least the configured cost parameters; no SHA-384 base64 strings written for new accounts.
|
||
|
||
---
|
||
|
||
### NFT-SEC-08: Argon2id Verify Has No Remotely Observable Timing Leak (AZ-536)
|
||
|
||
**Summary**: `VerifyPassword` is constant-time across wrong passwords of various lengths; timing variance does not leak information about the candidate password.
|
||
**Traces to**: AZ-536 AC-5
|
||
|
||
**Preconditions**:
|
||
- User with Argon2id-hashed password
|
||
- Test environment with low concurrency (this test is sensitive to host noise — if it intermittently trips, widen the bound or warm Argon2 with a non-test login first; see cycle-2 carry-forward F6)
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | POST /login with a wrong 8-char password, sample N=20 timings | Each → HTTP 409 WrongPassword |
|
||
| 2 | POST /login with a wrong 64-char password, sample N=20 timings | Each → HTTP 409 WrongPassword |
|
||
| 3 | Compute median of each sample; compare | `|median_8 − median_64| / median_8 < 0.20` (within 20% of each other — Argon2id cost dominates string-comparison cost) |
|
||
|
||
**Pass criteria**: Wrong-password verify time is dominated by Argon2id cost, not by string-length-dependent comparison; no exploitable timing channel.
|
||
|
||
---
|
||
|
||
### NFT-SEC-09: Per-IP Rate Limit Returns 429 (AZ-537)
|
||
|
||
**Summary**: 11 `/login` requests from the same client IP within 60 s force the 11th into HTTP 429 with a `Retry-After` header.
|
||
**Traces to**: AZ-537 AC-1
|
||
|
||
**Preconditions**:
|
||
- Rate-limit `Auth:RateLimit:PerIp` set to 10 / 60 s sliding (the test env value)
|
||
- Test client preserves source IP across requests (E2E container-shared-IP caveat applies — see test_run_report cycle 2 skip note for the legitimate environment-mismatch skip)
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | POST /login × 10 from the same IP within 5 s (any mix of right/wrong passwords) | HTTP 200 / HTTP 409 (within budget) |
|
||
| 2 | POST /login as the 11th request inside the 60 s window | HTTP 429; response includes `Retry-After` header (integer seconds) |
|
||
|
||
**Pass criteria**: 11th request inside the window is rejected with 429 + Retry-After. (Legitimate environment-mismatch skip in shared-IP container envs — verified by ASP.NET Core RateLimiter unit tests + manual probe documented in AZ-537 spec.)
|
||
|
||
---
|
||
|
||
### NFT-SEC-10: Per-Account Rate Limit Returns 429 (AZ-537)
|
||
|
||
**Summary**: 6 `/login` requests for the same email from 6 different IPs within 5 min force the 6th into HTTP 429.
|
||
**Traces to**: AZ-537 AC-2
|
||
|
||
**Preconditions**:
|
||
- Rate-limit `Auth:RateLimit:PerAccount` set to 5 / 5 min sliding
|
||
- Test ability to spoof / vary the source IP per request (e.g. via `X-Forwarded-For` if the app trusts a known forwarder, or a multi-host test fixture)
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | POST /login for `alice@azaion.com` from IPs 1..5 within 1 min (any mix of right/wrong passwords) | HTTP 200 / HTTP 409 (within budget) |
|
||
| 2 | POST /login for `alice@azaion.com` from IP 6 inside the 5 min window | HTTP 429; `Retry-After` present |
|
||
|
||
**Pass criteria**: Per-account partition triggers independently of per-IP partition.
|
||
|
||
---
|
||
|
||
### NFT-SEC-11: Account Lockout Returns 423 Even For Correct Password (AZ-537)
|
||
|
||
**Summary**: Once `failed_login_count` hits the lockout threshold, the account returns HTTP 423 Locked even for subsequent correct-password attempts until `lockout_until` passes.
|
||
**Traces to**: AZ-537 AC-3
|
||
|
||
**Preconditions**:
|
||
- `Auth:Lockout:MaxAttempts = 10` (default)
|
||
- User `bob@azaion.com` with Argon2id-hashed password
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | POST /login for `bob@azaion.com` with wrong password × 10 (across IPs / within rate budget) | First 9 → HTTP 409 WrongPassword; 10th → HTTP 423 Locked OR final 409 followed by lockout flag |
|
||
| 2 | Read `users.lockout_until` and `users.failed_login_count` for `bob` | `lockout_until > now()`; counter at threshold |
|
||
| 3 | POST /login for `bob` with correct password immediately after | HTTP 423 Locked (lockout precedes credential check) |
|
||
|
||
**Pass criteria**: Lockout state takes precedence over correct credentials within the lockout window; counter persists across IPs (per-account, not per-IP).
|
||
|
||
---
|
||
|
||
### NFT-SEC-12: Lockout Is Audit-Logged (AZ-537)
|
||
|
||
**Summary**: When NFT-SEC-11 fires the lockout transition, an audit-log row is written with the email, source IP, and timestamp.
|
||
**Traces to**: AZ-537 AC-6
|
||
|
||
**Preconditions**:
|
||
- Audit log infrastructure online (verified by existing logging tests)
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | Trigger NFT-SEC-11 against `bob@azaion.com` from IP `203.0.113.7` | Lockout fires |
|
||
| 2 | Query the audit log for entries with `event = 'login_lockout'` since the test start | At least one row with `email = 'bob@azaion.com'`, `ip = '203.0.113.7'`, `timestamp` within ± 5 s of the lockout trigger |
|
||
|
||
**Pass criteria**: Each lockout produces a `login_lockout` audit entry with the security-relevant fields.
|
||
|
||
---
|
||
|
||
### NFT-SEC-13: HTTP CORS Origin Is Rejected (AZ-538)
|
||
|
||
**Summary**: A browser preflight from the cleartext `http://admin.azaion.com` origin must NOT receive an `Access-Control-Allow-Origin` header (CORS denies the request).
|
||
**Traces to**: AZ-538 AC-1
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | OPTIONS /login with `Origin: http://admin.azaion.com`, `Access-Control-Request-Method: POST` | HTTP 204 OR 200; response has NO `Access-Control-Allow-Origin` header |
|
||
|
||
**Pass criteria**: HTTP origin gets no ACAO header — browser-side fetch with credentials will fail in any compliant browser.
|
||
|
||
---
|
||
|
||
### NFT-SEC-14: HSTS Header Present in Production (AZ-538)
|
||
|
||
**Summary**: When `ASPNETCORE_ENVIRONMENT=Production`, every HTTPS response includes a strict `Strict-Transport-Security` header.
|
||
**Traces to**: AZ-538 AC-3
|
||
|
||
**Preconditions**:
|
||
- Admin container running with `ASPNETCORE_ENVIRONMENT=Production`
|
||
- Note: the default test harness runs `Development`; this test must be run with the production env override OR is the legitimate environment-mismatch skip documented in cycle-2 test_run_report
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | GET https://admin.azaion.com/health/live (or any HTTPS endpoint) | HTTP 200; response header `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` |
|
||
|
||
**Pass criteria**: Production responses always carry HSTS with the documented directives.
|
||
|
||
---
|
||
|
||
### NFT-SEC-15: HTTP Request Redirects to HTTPS in Production (AZ-538)
|
||
|
||
**Summary**: When `ASPNETCORE_ENVIRONMENT=Production`, a cleartext HTTP request returns HTTP 307 to the same path on HTTPS.
|
||
**Traces to**: AZ-538 AC-4
|
||
|
||
**Preconditions**: Same as NFT-SEC-14
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | GET http://admin.azaion.com/health/live | HTTP 307; `Location: https://admin.azaion.com/health/live` |
|
||
|
||
**Pass criteria**: HTTP traffic is redirected at the protocol layer, not silently served.
|
||
|
||
---
|
||
|
||
### NFT-SEC-16: Refresh-Token Reuse Kills the Session Family (AZ-531)
|
||
|
||
**Summary**: If a previously-rotated refresh token is presented again, the entire `sessions` family chain (parent + all descendants) is marked `revoked_reason='reuse_detected'` and every refresh in that family stops working.
|
||
**Traces to**: AZ-531 AC-3
|
||
|
||
**Preconditions**:
|
||
- A session family with refresh R1 rotated to R2 (per FT-P-31)
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | POST /token/refresh with R1 (already rotated) | HTTP 401 |
|
||
| 2 | Query `sessions` for the family | Every row in the family has `revoked_at` set; `revoked_reason = 'reuse_detected'` |
|
||
| 3 | POST /token/refresh with R2 | HTTP 401 (R2 also dead — family-wide kill) |
|
||
|
||
**Pass criteria**: Reuse detection kills the entire family, not just the reused refresh.
|
||
|
||
---
|
||
|
||
### NFT-SEC-17: Refresh Tokens Are Opaque, Not JWT (AZ-531)
|
||
|
||
**Summary**: Refresh tokens issued by /login or /token/refresh are not JWTs; the persisted form is the SHA-256 hash; the raw value never appears in logs.
|
||
**Traces to**: AZ-531 AC-5
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | POST /login → capture `refresh_token` R | R is a non-empty string ≥ 43 chars (base64url of 32 bytes) |
|
||
| 2 | Attempt to parse R as a JWT (split on `.` and base64url-decode the segments) | Parse fails — R does not split into a JWT header/payload/signature shape |
|
||
| 3 | Read the matching `sessions.refresh_hash` column directly from Postgres | Length 32 bytes (SHA-256 raw or base64-encoded), value ≠ R |
|
||
| 4 | Grep API logs (Serilog output) for the literal R | No match (raw refresh value never logged) |
|
||
|
||
**Pass criteria**: Refresh tokens are opaque, hashed at rest, and never logged in raw form.
|
||
|
||
---
|
||
|
||
### NFT-SEC-18: Admin Tokens Are Signed With ES256 + kid (AZ-532)
|
||
|
||
**Summary**: An access token returned by /login has `alg=ES256` and a `kid` matching one of the active JWKS keys.
|
||
**Traces to**: AZ-532 AC-1
|
||
|
||
**Preconditions**:
|
||
- Admin running with at least one ES256 keypair loaded from `secrets/jwt_signing_key.pem`
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | POST /login with valid credentials | HTTP 200, dual tokens |
|
||
| 2 | Decode the access token's JOSE header | `alg == "ES256"`, `kid` non-empty |
|
||
| 3 | GET /.well-known/jwks.json | The same `kid` appears in the returned `keys` array |
|
||
|
||
**Pass criteria**: Tokens are signed asymmetrically and carry the `kid` discriminator needed for rotation.
|
||
|
||
---
|
||
|
||
### NFT-SEC-19: JWKS Endpoint Never Exposes Private Material (AZ-532)
|
||
|
||
**Summary**: The JWKS payload contains only public components; no `d`, `p`, `q`, `dp`, `dq`, or `qi` field appears.
|
||
**Traces to**: AZ-532 AC-4
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | GET /.well-known/jwks.json | HTTP 200, JSON body |
|
||
| 2 | Inspect every entry in `keys` for forbidden private-material fields | None of `d`, `p`, `q`, `dp`, `dq`, `qi` is present |
|
||
|
||
**Pass criteria**: Public-key set strictly excludes any private scalar (EC) or RSA private primes.
|
||
|
||
---
|
||
|
||
### NFT-SEC-20: alg-Confusion Attack Is Rejected (AZ-532)
|
||
|
||
**Summary**: A forged token with `alg=HS256` (where the signature is computed using the public key as the HMAC secret) is rejected by every protected endpoint, because `TokenValidationParameters.ValidAlgorithms` pins ES256 only.
|
||
**Traces to**: AZ-532 AC-5
|
||
|
||
**Preconditions**:
|
||
- Test fixture able to construct a forged JWT given the public key
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | Build a JWT with header `{ "alg":"HS256","typ":"JWT","kid":"<active-kid>" }`; payload claims valid; signature = HMAC-SHA256(publicKeyBytes, signingInput) | Forged token string |
|
||
| 2 | GET /users with the forged token | HTTP 401 |
|
||
|
||
**Pass criteria**: Algorithm-confusion forgery is rejected; verifier does not silently downgrade to HS256.
|
||
|
||
---
|
||
|
||
### NFT-SEC-21: Mission Token Requires MFA Step-Up (AZ-533 + AZ-534)
|
||
|
||
**Summary**: After AZ-534 ships, `POST /sessions/mission` MUST reject access tokens whose `amr` does not include `mfa`. Caller gets 403 with a step-up message.
|
||
**Traces to**: AZ-533 AC-6
|
||
|
||
**Preconditions**:
|
||
- AZ-534 already landed (it has — cycle 2 batch 4)
|
||
- Caller holds an access token with `amr=["pwd"]` (e.g. legacy session, or a service account that doesn't enroll MFA)
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | POST /sessions/mission with the `amr=["pwd"]` access token + a valid mission body | HTTP 403; response body contains `"mission tokens require step-up MFA"` |
|
||
|
||
**Pass criteria**: Mission-class tokens cannot be minted without MFA in the access-token `amr` chain.
|
||
|
||
> Note: cycle-2 follow-up F1 in `_docs/03_implementation/implementation_report_auth_modernization_cycle2.md` calls out that `/sessions/mission` enforcement of `amr=mfa` is the small wire-up still pending after AZ-534 shipped (the AC was deferred during AZ-533, then re-opened under F1). Until F1 lands, this scenario is the spec contract; the matching test may be marked Pending in the SUT.
|
||
|
||
---
|
||
|
||
### NFT-SEC-22: TOTP Secret Is Encrypted at Rest (AZ-534)
|
||
|
||
**Summary**: The `users.mfa_secret` column never holds plaintext base32; only ciphertext.
|
||
**Traces to**: AZ-534 AC-6
|
||
|
||
**Preconditions**:
|
||
- An enrolled user from FT-P-39
|
||
|
||
**Steps**:
|
||
|
||
| Step | Consumer Action | Expected Response |
|
||
|------|----------------|------------------|
|
||
| 1 | Read `users.mfa_secret` for the enrolled user directly from Postgres | Value is non-empty |
|
||
| 2 | Try to base32-decode the value as if it were a 32-char TOTP secret | Decode either fails OR yields material that does NOT round-trip to a working TOTP code |
|
||
| 3 | Confirm the value is the output of `IDataProtector.Protect(<plaintext base32>)` (length ≫ 32 chars; format-prefixed) | Matches `IDataProtector` ciphertext shape |
|
||
|
||
**Pass criteria**: `mfa_secret` is stored encrypted; reading the DB row alone does not yield a usable TOTP secret. (Operational note: production must set `DataProtection:KeysFolder` for the `IDataProtector` to outlive container restarts — see cycle-2 carry-forward F3.)
|