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), #279 (quota/expiry issuance arguments), #63 (SPA key intake and badge — separate QA plan: plan-spa-key-badge).

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.

TypeCoverage
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
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
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
  • 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, #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-days integration 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 in api_keys and opens no connection on either path
  • 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)
  • 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 authorization key in log output for all three handlers

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.

pytest, pytest-cov, argon2-cffi, unittest.mock (mocked rds-data client for Data API paths), 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, #279): all rows in the #60, #61, #62, #279 section. Exit criteria cover each row.