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), #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). 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: bcrypt 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; 10 successive valid calls reduce count to 0 and the 11th 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 |
| 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 |
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 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
- 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)
- Log scrub: unit test asserts no
authorizationkey in log output for all three handlers
Environments
Section titled “Environments”Unit and integration: CI (Docker Postgres). IaC: CI. QA integration: manual against QA env.
Tooling
Section titled “Tooling”pytest, pytest-cov, bcrypt, 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): all rows in the #60, #61, #62 section. Exit criteria cover each row.
© 2026 Benjamin Arunski