Skip to content

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).

This plan covers the backend authentication stories (#60, #61, #62). SPA key intake (#63) is covered in plan-spa-key-badge.

TypeCoverage
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
ContractOpenAPI 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
  • Story #13 (/query endpoint) merged
  • Story #10 (local DB) with api_keys table migration merged
  • Story #23 (API module + API GW) deployed to QA
  • 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 plan JSON; IAM role scoped (tfsec passes)
  • Contract: OpenAPI openapi-check CI 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 authorization key in log output for all three handlers

Unit and integration: CI (Docker Postgres). IaC: CI. QA integration: manual against QA env.

pytest, pytest-cov, bcrypt, concurrent.futures (race test), Terraform, tfsec, AWS CLI, Newman.

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 == 10
  • FOR UPDATE requires 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 PGSSLMODE or connection retry in the authorizer handler.

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.