QA plan: API authentication — bearer token authorizer (#59)
Feature: API authentication (#59)
Capability: Security (#45)
ADR: RAG009 — bearer token Lambda authorizer, Aurora-backed key store, operator CLI, SPA visible key
Stories under this Feature: #60 (authorizer Lambda), #61 (issue-key CLI), #62 (log scrub), #279 (quota/expiry issuance arguments), #63 (SPA key intake and badge — separate QA plan: plan-spa-key-badge).
Test scope
Section titled “Test scope”This plan covers the backend authentication stories (#60, #61, #62) and #279, which makes the per-key quota and expiry into issuance-time CLI arguments. SPA key intake (#63) is covered in plan-spa-key-badge.
| Type | Coverage |
|---|---|
| Unit (#60) | parse_bearer: valid rks_<id>_<secret> parses correctly; missing header returns None; malformed token returns None |
| Unit (#60) | verify_hash: Argon2id hash comparison correct; wrong secret returns False |
| Unit (#60) | Authorizer returns allow when key is valid, not expired, and has remaining requests > 0 |
| Unit (#60) | Authorizer returns deny on expired key (expires_at <= now()) |
| Unit (#60) | Authorizer returns deny on exhausted key (remaining_requests <= 0) |
| Unit (#60) | Authorizer returns deny on hash mismatch |
| Integration (#60) | remaining_requests decrements by 1 per successful authorization; for a key issued with quota N, N successive valid calls reduce the count to 0 and call N+1 returns deny |
| Integration (#60) | FOR UPDATE atomicity: concurrent valid requests do not produce a final count below 0 (race-safety) |
| IaC (#60) | Authorizer cache TTL is 0 in the API Gateway authorizer resource |
| IaC (#60) | Authorizer Lambda IAM role: rds-data:ExecuteStatement on cluster ARN + secretsmanager:GetSecretValue on credential secret ARN only |
| Unit (#61) | Key generation uses secrets.token_bytes; the plaintext key is not stored in the DB (only the hash) |
| Integration (#61) | python -m rag_sample.tools.issue_key --label="test" inserts exactly one row in api_keys; key_secret_hash is not equal to the printed key |
| Integration (#279) | Omitting --requests and --expires-days issues a key with the defaults (10 requests / 60 days) on the psycopg path (issue_key) against Docker Postgres |
| Integration (#279) | --requests 100 --expires-days 180 issues a key whose remaining_requests is 100 and whose expires_at is 180 days out, on the psycopg path |
| Integration (#279) | Boundary values --requests 1, --requests 1000, --expires-days 1, --expires-days 365 are accepted on the psycopg path |
| Integration (#279) | Issuance persists the Argon2id hash only on the psycopg path: with --requests and --expires-days bound into the insert, key_secret_hash is still not equal to the printed key |
| Unit (#279) | Data API path (issue_key_data_api.DataApiKeyStore.issue_key), driven by a mocked rds-data client: the execute_statement parameter set binds the operator-supplied quota and expiry, the documented defaults when the arguments are omitted, and the Argon2id hash rather than the plaintext key |
| Unit (#279) | Rejected with a clear message and no row written: --requests of 0, negative, non-integer, or above 1000; --expires-days of 0, negative, non-integer, or above 365. Validation lives in the shared CLI argument parser, ahead of the psycopg/Data API path selection, so a rejected value opens no connection on either path |
| Unit (#279) | Issuance output echoes the quota and expiry actually applied, defaulted or explicit |
| Unit (#62) | Lambda Powertools Logger configured so Authorization header is absent from log output under any casing (all three handlers: API Lambda, authorizer Lambda) |
| Unit (#62) | Structured log for a request includes request_id and level but not authorization |
| Contract | OpenAPI spec carries a bearer security scheme applied to /query; committed openapi.yaml matches FastAPI output (RAG005) |
| Integration (QA) | POST /query without Authorization header returns 401 from API Gateway (authorizer denies before Lambda invoke) |
| Integration (QA) | POST /query with a valid bearer token and remaining requests returns 200 |
| Integration (QA) | POST /query with an expired or exhausted key returns 401 |
| Integration (QA, #279) | Data API issuance path: with the QA Data API descriptor env vars set (DB_CLUSTER_ARN / DB_SECRET_ARN / DB_NAME), python -m rag_sample.tools.issue_key --label="qa-279" --requests 2 --expires-days 1 inserts a row in QA Aurora that list shows with remaining_requests 2 and expires_at 1 day out; the issued key returns 200 on two POST /query calls and 401 on the third; the key is revoked afterwards |
Entry criteria
Section titled “Entry criteria”- Story #13 (
/queryendpoint) merged - Story #10 (local DB) with
api_keystable migration merged - Story #23 (API module + API GW) deployed to QA
Exit criteria
Section titled “Exit criteria”- All unit tests for #60, #61, #62, #279 pass; authorizer module, CLI tool, and log-scrub code each exceed the 70% per-file coverage floor
- Integration: decrement-to-zero and race-safety tests pass against Docker Postgres in CI
- Issuance:
--requests/--expires-daysintegration tests pass against Docker Postgres on the psycopg path, defaults included, and the Data API path’s bound parameter set is asserted by unit test; every rejected value leaves no row inapi_keysand opens no connection on either path - IaC: authorizer cache TTL = 0 confirmed in
terraform planJSON; IAM role scoped (tfsec passes) - Contract: OpenAPI
openapi-checkCI job passes with bearer scheme on/query - QA integration: 401 without token, 200 with valid token, 401 with exhausted/expired key (manual)
- QA issuance: a key issued over the Data API path against QA Aurora carries the requested quota and expiry, and the authorizer allows it up to that quota and denies the next call (manual)
- Log scrub: unit test asserts no
authorizationkey in log output for all three handlers
Environments
Section titled “Environments”Unit and integration: CI (Docker Postgres). Docker Postgres cannot serve rds-data:ExecuteStatement and no Data API emulator is used, so Data API code paths stay at unit level with a mocked rds-data client; end-to-end Data API behavior is covered by the manual QA-environment rows. IaC: CI. QA integration: manual against QA env.
Tooling
Section titled “Tooling”pytest, pytest-cov, argon2-cffi, unittest.mock (mocked rds-data client for Data API paths), concurrent.futures (race test), Terraform, tfsec, AWS CLI, Newman.
How tests are written
Section titled “How tests are written”def test_authorizer_denies_exhausted_key(db_session, make_test_key): # Given a key with remaining_requests = 0 token = make_test_key(remaining=0, days_offset=30) event = build_authorizer_event(token) # When the authorizer handler processes the event result = authorizer_handler(event, {}) # Then the policy effect is Deny assert result["policyDocument"]["Statement"][0]["Effect"] == "Deny"
def test_decrement_race_safety(db_session, make_test_key): # Given a key with exactly 10 remaining requests and 10 concurrent callers token = make_test_key(remaining=10, days_offset=30) events = [build_authorizer_event(token) for _ in range(12)] # When all 12 authorizer calls run concurrently with concurrent.futures.ThreadPoolExecutor(max_workers=12) as ex: results = list(ex.map(lambda e: authorizer_handler(e, {}), events)) # Then exactly 10 are Allow and 2 are Deny; remaining_requests never goes below 0 allows = sum(1 for r in results if r["policyDocument"]["Statement"][0]["Effect"] == "Allow") assert allows == 10FOR UPDATErequires the Data API transaction to be open across both the SELECT and UPDATE statements; verify the Mangum/Data API client supports explicit transactions.- Aurora cold start (auto-pause) can add 20–30 s to the first authorizer call after idle; set a
PGSSLMODEor connection retry in the authorizer handler.
Capability acceptance criteria link
Section titled “Capability acceptance criteria link”RAG009: all acceptance criteria in the ADR Consequences section. Security per-feature requirements (#59–#63, #279): all rows in the #60, #61, #62, #279 section. Exit criteria cover each row.
© 2026 Benjamin Arunski