---
title: 'Migrate HMAC Auth to textiq-hmac Package'
slug: 'migrate-hmac-to-textiq-hmac'
created: '2026-03-17'
status: 'implementation-complete'
stepsCompleted: [1, 2, 3, 4]
tech_stack: ['Python 3.11+', 'FastAPI', 'SQLAlchemy', 'boto3', 'Airflow 3.0.6', 'uv', 'hatchling']
files_to_modify: ['airflow/plugins/api/auth.py', 'airflow/plugins/api/upload_blueprint.py', 'scripts/test_upload_endpoint.py', 'docker/Dockerfile.airflow', 'pyproject.toml']
code_patterns: ['Airflow plugins use sync defs in FastAPI threadpool', 'verify_context_headers takes plain dict not request object', 'AuthError caught in two places in upload_blueprint.py (line 63 + line 174)']
test_patterns: ['No existing unit tests for auth.py', 'pytest with asyncio_mode=auto', 'test script at scripts/test_upload_endpoint.py for manual E2E']
---

# Tech-Spec: Migrate HMAC Auth to textiq-hmac Package

**Created:** 2026-03-17

## Overview

### Problem Statement

The Airflow plugin (`airflow/plugins/api/auth.py`) contains a custom HMAC-SHA256 implementation that duplicates logic now provided by the `textiq-hmac` package (v0.1.0). This creates maintenance burden and divergence risk — the package already supports additional roles (`org_manager`, `delivery_manager`, `viewer`) and key rotation utilities that the custom code lacks.

### Solution

Replace the custom HMAC implementation with `textiq_hmac` package calls (`load_key_ring`, `verify_context_headers`, `VerificationError`), update all callers, migrate the test script to use `sign_context_headers`, and add the package to the Airflow Docker image.

### Scope

**In Scope:**
- Rewrite `airflow/plugins/api/auth.py` to delegate to `textiq_hmac`
- Update `airflow/plugins/api/upload_blueprint.py` to catch `VerificationError`
- Update `scripts/test_upload_endpoint.py` to use `sign_context_headers()`
- Add `textiq-hmac` install to `docker/Dockerfile.airflow`

**Out of Scope:**
- Key rotation workflow setup
- Changes to `src/core/config.py`
- Any other service's HMAC code

## Context for Development

### Codebase Patterns

- Airflow plugins use sync defs; FastAPI runs them in a threadpool (needed for blocking boto3/SQLAlchemy)
- The upload endpoint is a FastAPI app mounted at `/api/v1/documents` via `AirflowPlugin.fastapi_apps`
- `verify_context_headers(headers: dict, key_ring: dict)` takes a plain dict — `auth.py` must still extract headers from the FastAPI `Request` object before calling it
- `AuthError` is caught in **two places** in `upload_blueprint.py`: inner try (line 63) and outer try (line 174) — both must switch to `VerificationError`
- `pyproject.toml` uses hatchling build, has no `[tool.uv.sources]` section yet
- No existing unit tests for `auth.py`; only manual E2E via `scripts/test_upload_endpoint.py`
- Dockerfile uses `uv sync --frozen --no-install-project` with `UV_HTTP_TIMEOUT=300`

### Files to Reference

| File | Purpose |
| ---- | ------- |
| `airflow/plugins/api/auth.py` | Custom HMAC auth (164 lines) — to be rewritten as thin wrapper |
| `airflow/plugins/api/upload_blueprint.py` | Caller: imports `AuthError`, `verify_hmac_headers` (line 24); catches in lines 63, 174 |
| `scripts/test_upload_endpoint.py` | Manual E2E test with inline `sign_request()` (lines 35-57) — migrate to `sign_context_headers` |
| `docker/Dockerfile.airflow` | Airflow image (46 lines) — needs wheel copy + `uv pip install` |
| `pyproject.toml` | Project deps — needs `textiq-hmac>=0.1.0` + `[tool.uv.sources]` for local dev |
| `.venv/lib/python3.12/site-packages/textiq_hmac/__init__.py` | Package public API reference |

### Technical Decisions

- Use `load_key_ring()` per-request (no module-level caching) — simpler, env var reads are fast
- Raise `VerificationError` directly — no `AuthError` alias
- Follow `textiq_hmac` role set (expanded: includes `org_manager`, `delivery_manager`, `viewer`)
- `verify_context_headers` takes a plain dict, so `auth.py` keeps its role as request-to-dict adapter
- Dockerfile integration per updated docs: copy wheel, `sed` out `[tool.uv.sources]`, `uv pip install` the wheel, then `uv pip install .`
- Add `textiq-hmac>=0.1.0` to `pyproject.toml` dependencies + `[tool.uv.sources]` for local editable dev
- Signature format (base64 vs hex) is handled entirely by the package — no format concern in our code

## Implementation Plan

### Tasks

- [x] Task 1: Add `textiq-hmac` to project dependencies
  - File: `pyproject.toml`
  - Action: Add `"textiq-hmac>=0.1.0"` to `[project] dependencies` array. Add `[tool.uv.sources]` section with local editable path for development: `textiq-hmac = { path = "../textiq-HMAC-module/textiq-hmac", editable = true }`
  - Notes: The `[tool.uv.sources]` section is stripped by `sed` during Docker build so it won't affect container builds

- [x] Task 2: Rewrite `auth.py` to delegate to `textiq_hmac`
  - File: `airflow/plugins/api/auth.py`
  - Action: Replace the entire module body. Keep the module as a thin adapter that: (1) extracts the 7 HMAC headers from a FastAPI `Request` object into a plain dict, (2) calls `load_key_ring()` and `verify_context_headers()`, (3) re-exports `VerificationError` for callers. Remove all custom logic: `_load_key_ring`, `_key_ring_cache`, `VALID_ROLES`, `SIGNED_HEADERS`, `REQUIRED_HEADERS`, signature computation, timestamp checking, UUID validation, and `AuthError` class. The function signature `verify_hmac_headers(request) -> dict` stays the same (returns `{"tenant_id", "node_id", "user_id", "role"}`).
  - Notes: The 7 required header names to extract: `X-Tenant-ID`, `X-Node-ID`, `X-User-ID`, `X-Role`, `X-Timestamp`, `X-Key-ID`, `X-Signature`. `VerificationError` from the package already raises the same error messages as the old `AuthError`.

- [x] Task 3: Update `upload_blueprint.py` to use `VerificationError`
  - File: `airflow/plugins/api/upload_blueprint.py`
  - Action: Change import on line 24 from `from api.auth import AuthError, verify_hmac_headers` to `from api.auth import VerificationError, verify_hmac_headers`. Replace `except AuthError as e:` with `except VerificationError as e:` in both locations (inner try at line 63 and outer try at line 174).
  - Notes: Response format stays the same — `JSONResponse({"detail": str(e)}, status_code=401)`

- [x] Task 4: Migrate test script to use `sign_context_headers`
  - File: `scripts/test_upload_endpoint.py`
  - Action: Replace the inline `sign_request()` function (lines 35-57) and its stdlib imports (`hashlib`, `hmac`) with `textiq_hmac.sign_context_headers` and `textiq_hmac.load_key_ring`. The `sign_request` function should build a headers dict with `X-Tenant-ID`, `X-Node-ID`, `X-User-ID`, `X-Role` and pass it to `sign_context_headers(headers, key_ring)` which returns the complete signed dict. `load_key_ring()` reads from `HMAC_KEY_RING` env var (already set in `.env`). Remove `--key-id` and `--secret` CLI args since the key ring env var provides these. Keep `--tenant-id`, `--node-id`, `--user-id`, `--role` args.
  - Notes: For dev convenience, set `HMAC_KEY_RING` env var before running, or ensure `.env` is loaded. The `DEFAULT_KEY_ID` and `DEFAULT_SECRET` constants are no longer needed.

- [x] Task 5: Add `textiq-hmac` wheel install to Airflow Dockerfile
  - File: `docker/Dockerfile.airflow`
  - Action: After the `COPY --chown=airflow:root pyproject.toml uv.lock /opt/airflow/` line, add: (1) `COPY --chown=airflow:root textiq_hmac-*.whl /tmp/` to copy the wheel into build context. (2) In the existing `RUN uv sync` step, prepend `sed -i '/\[tool\.uv\.sources\]/,/^$/d' pyproject.toml &&` to strip local dev sources, then add `uv pip install /tmp/textiq_hmac-*.whl &&` before `uv sync`.
  - Notes: The wheel file must be present in the Docker build context root. The `sed` command removes the `[tool.uv.sources]` block so uv doesn't try to resolve the local path inside Docker.

### Acceptance Criteria

- [ ] AC 1: Given a request with valid HMAC headers signed by `textiq_hmac.sign_context_headers`, when `verify_hmac_headers(request)` is called, then it returns `{"tenant_id": "...", "node_id": "...", "user_id": "...", "role": "..."}` without error
- [ ] AC 2: Given a request with missing `X-Signature` header, when `verify_hmac_headers(request)` is called, then `VerificationError("Missing header: X-Signature")` is raised
- [ ] AC 3: Given a request with an invalid signature, when `verify_hmac_headers(request)` is called, then `VerificationError("Invalid signature")` is raised
- [ ] AC 4: Given a request with a timestamp older than 30 seconds, when `verify_hmac_headers(request)` is called, then `VerificationError("Request expired or clock drift too large")` is raised
- [ ] AC 5: Given a request with `X-Role: org_manager` (new role from package), when `verify_hmac_headers(request)` is called, then it succeeds (role is valid per `textiq_hmac`)
- [ ] AC 6: Given the upload endpoint receives a request with invalid HMAC, when the handler runs, then it returns HTTP 401 with `{"detail": "<error message>"}`
- [ ] AC 7: Given `textiq-hmac` is not installed in the Airflow container, when `docker build` runs with the updated Dockerfile, then the wheel is installed and `import textiq_hmac` succeeds
- [ ] AC 8: Given the test script `scripts/test_upload_endpoint.py` with `HMAC_KEY_RING` env var set, when `python scripts/test_upload_endpoint.py --check` is run against a live endpoint, then the no-auth test returns 401 and the signed upload returns 202

## Additional Context

### Dependencies

- `textiq-hmac` v0.1.0 wheel file (must be in Docker build context root and installed locally)
- `HMAC_KEY_RING` or `HMAC_KEY_RING_FILE` env var must be set for both Airflow runtime and test script

### Testing Strategy

- **Manual E2E**: Run `scripts/test_upload_endpoint.py` against the Airflow webserver after rebuilding the Docker image. Verify 401 for unsigned requests and 202 for signed uploads.
- **Docker build verification**: `docker build -f docker/Dockerfile.airflow .` succeeds and `docker run --rm <image> python -c "from textiq_hmac import verify_context_headers; print('ok')"` works.
- **Smoke test sign/verify round-trip**: In a Python shell, use `sign_context_headers` + `verify_context_headers` with a dev key ring to confirm the package works end-to-end.

### Notes

- The `textiq_hmac` package validates signature before role/timestamp to prevent information leakage — this is a behavioral change from the old code which validated fields in order. The error messages are the same, but the order of checks differs.
- The old `auth.py` cached the key ring at module level. The new version calls `load_key_ring()` per request. This is fine for performance (env var reads are fast) and better for key rotation (new keys are picked up without process restart).
- The wheel file must be committed or made available in CI. Coordinate with the build pipeline to ensure `textiq_hmac-0.1.0-py3-none-any.whl` is accessible during `docker build`.
