3 plans (Wave 0 test scaffolding, Wave 1 jti+NBF check, Wave 2 user_nbf writes), VALIDATION.md, RESEARCH.md with resolved open questions. Checker: 0 blockers, 1 warning resolved (RESEARCH.md open questions marked). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
331 lines
19 KiB
Markdown
331 lines
19 KiB
Markdown
---
|
||
phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted
|
||
plan: 03
|
||
type: execute
|
||
wave: 2
|
||
depends_on:
|
||
- 07.2-02
|
||
files_modified:
|
||
- backend/api/auth.py
|
||
- backend/api/admin.py
|
||
- backend/tests/test_auth_api.py
|
||
- backend/tests/test_admin_api.py
|
||
autonomous: true
|
||
requirements:
|
||
- CONCERNS:JTI-CLAIM
|
||
- CONCERNS:JTI-REVOKE-REDIS
|
||
tags:
|
||
- security
|
||
- jwt
|
||
- redis
|
||
- access-control
|
||
|
||
must_haves:
|
||
truths:
|
||
- "change_password writes user_nbf:{user_id} to Redis before session.commit() (D-02)"
|
||
- "enable_totp writes user_nbf:{user_id} to Redis before session.commit() (D-02)"
|
||
- "disable_totp writes user_nbf:{user_id} to Redis before session.commit() (D-02)"
|
||
- "admin deactivation writes user_nbf:{user_id} to Redis only when is_active=False (D-05)"
|
||
- "admin activation (is_active=True) does NOT write user_nbf (anti-pattern guard)"
|
||
- "TTL for all writes is settings.access_token_expire_minutes * 60 (not hardcoded 900)"
|
||
- "import time added at module level to api/auth.py and api/admin.py"
|
||
artifacts:
|
||
- path: "backend/api/auth.py"
|
||
provides: "user_nbf Redis write in change_password, enable_totp, disable_totp"
|
||
contains: "user_nbf"
|
||
- path: "backend/api/admin.py"
|
||
provides: "user_nbf Redis write in deactivation handler"
|
||
contains: "user_nbf"
|
||
key_links:
|
||
- from: "backend/api/auth.py change_password"
|
||
to: "request.app.state.redis"
|
||
via: "await redis.set(f'user_nbf:{current_user.id}', int(time.time()), ex=settings.access_token_expire_minutes * 60)"
|
||
pattern: "user_nbf.*current_user\\.id"
|
||
- from: "backend/api/admin.py deactivation handler (line ~351)"
|
||
to: "request.app.state.redis"
|
||
via: "conditional write only when not body.is_active"
|
||
pattern: "user_nbf.*user\\.id"
|
||
- from: "Wave 2 implementation"
|
||
to: "Wave 0 xfail stubs (test_auth_api NBF-write × 3, test_admin_api NBF-write × 1)"
|
||
via: "stubs promoted from XFAIL to PASSED"
|
||
pattern: "XPASS|xfail.*strict=False"
|
||
---
|
||
|
||
<objective>
|
||
Wire the user_nbf Redis writes into the four security-event handlers that trigger
|
||
access-token revocation.
|
||
|
||
Purpose: Plan 02 established the check (reader); this plan adds the writers. Until
|
||
these four writes are in place, the user_nbf:{user_id} key is never set in
|
||
production (it only exists in tests via direct FakeRedis manipulation), so the
|
||
15-minute revocation window remains open.
|
||
|
||
This plan is the final implementation step for Phase 7.2. After it completes:
|
||
- Any password change, TOTP enroll, TOTP revoke, or admin deactivation writes
|
||
user_nbf:{user_id} = now() to Redis with TTL = access token lifetime.
|
||
- get_current_user (Plan 02) reads the key and blocks pre-event tokens.
|
||
- All Wave 0 xfail stubs from Plan 01 that weren't promoted in Plan 02 flip to PASSED.
|
||
|
||
Output: Two files edited (api/auth.py, api/admin.py), four test stubs promoted.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-CONTEXT.md
|
||
@.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-RESEARCH.md
|
||
@backend/api/auth.py
|
||
@backend/api/admin.py
|
||
@backend/tests/test_auth_api.py
|
||
@backend/tests/test_admin_api.py
|
||
@backend/config.py
|
||
|
||
<interfaces>
|
||
<!-- change_password handler — backend/api/auth.py:455 -->
|
||
|
||
Handler already calls:
|
||
1. auth_service.verify_password
|
||
2. auth_service.check_hibp
|
||
3. auth_service.validate_password_strength
|
||
4. auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
|
||
5. write_audit_log(...)
|
||
6. await session.commit() ← insert user_nbf write before this line
|
||
|
||
The redis_client is NOT yet assigned in change_password — use request.app.state.redis inline.
|
||
|
||
<!-- enable_totp handler — backend/api/auth.py:552 -->
|
||
|
||
Handler already has at the top:
|
||
redis_client = request.app.state.redis ← reuse this variable
|
||
Then calls:
|
||
1. auth_service.verify_totp(..., redis_client)
|
||
2. user.totp_enabled = True
|
||
3. auth_service.generate_backup_codes / store_backup_codes
|
||
4. auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
|
||
5. write_audit_log(...)
|
||
6. await session.commit() ← insert user_nbf write before this line
|
||
(redis_client already in scope — use it directly)
|
||
|
||
<!-- disable_totp handler — backend/api/auth.py:607 -->
|
||
|
||
Handler does NOT yet have a redis_client assignment.
|
||
Calls:
|
||
1. user.totp_enabled = False; user.totp_secret = None
|
||
2. delete(BackupCode) for user
|
||
3. auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
|
||
4. write_audit_log(...)
|
||
5. await session.commit() ← insert user_nbf write before this line
|
||
|
||
<!-- admin deactivation handler — backend/api/admin.py:326 (PATCH /api/admin/users/{id}/status) -->
|
||
|
||
Handler at line ~351:
|
||
if not body.is_active:
|
||
# Revoke all refresh tokens on deactivation
|
||
await revoke_all_refresh_tokens(session, user.id)
|
||
← INSERT user_nbf write HERE (inside the if not body.is_active block, after revoke_all_refresh_tokens)
|
||
← Do NOT write if body.is_active is True (anti-pattern from RESEARCH.md)
|
||
|
||
admin.py does NOT currently import `time` (grep confirms no `import time` at module level).
|
||
auth.py does NOT currently import `time` (confirmed by codebase read).
|
||
|
||
<!-- TTL constant -->
|
||
|
||
settings.access_token_expire_minutes = 15 (from backend/config.py)
|
||
TTL = settings.access_token_expire_minutes * 60 (= 900 at default config)
|
||
Use this derived form — do NOT hardcode 900. Import `settings` is already present in api/auth.py.
|
||
In api/admin.py, verify `settings` is importable (it uses `from config import settings` per existing imports).
|
||
|
||
<!-- Redis write pattern (from RESEARCH.md Pattern 2, line 176) -->
|
||
|
||
await request.app.state.redis.set(
|
||
f"user_nbf:{current_user.id}",
|
||
int(time.time()),
|
||
ex=settings.access_token_expire_minutes * 60,
|
||
)
|
||
|
||
In enable_totp where redis_client is already assigned:
|
||
await redis_client.set(
|
||
f"user_nbf:{current_user.id}",
|
||
int(time.time()),
|
||
ex=settings.access_token_expire_minutes * 60,
|
||
)
|
||
|
||
In admin deactivation, the user variable is the target (not current_user):
|
||
await request.app.state.redis.set(
|
||
f"user_nbf:{user.id}",
|
||
int(time.time()),
|
||
ex=settings.access_token_expire_minutes * 60,
|
||
)
|
||
</interfaces>
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto">
|
||
<name>Task 1: Add import time + user_nbf writes to api/auth.py (change_password, enable_totp, disable_totp)</name>
|
||
<files>backend/api/auth.py</files>
|
||
<read_first>
|
||
- backend/api/auth.py lines 24–35 (module-level imports — add `import time` here, after existing stdlib imports)
|
||
- backend/api/auth.py lines 455–510 (change_password handler — find the `await session.commit()` line, insert write above it)
|
||
- backend/api/auth.py lines 552–605 (enable_totp handler — redis_client already in scope; find `await session.commit()`, insert write above it)
|
||
- backend/api/auth.py lines 607–650 (disable_totp handler — find `await session.commit()`, insert write above it)
|
||
- backend/config.py (confirm access_token_expire_minutes field name — e.g., `access_token_expire_minutes: int = 15`)
|
||
- .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-RESEARCH.md "Pattern 2" section (lines 167–184) and "Anti-Patterns" (do NOT write on activation)
|
||
</read_first>
|
||
<behavior>
|
||
- change_password: after revoke_all_refresh_tokens + write_audit_log and before session.commit(), Redis key user_nbf:{current_user.id} is set with TTL=access_token_expire_minutes*60.
|
||
- enable_totp: same write, using the already-assigned redis_client variable, before session.commit().
|
||
- disable_totp: same write via request.app.state.redis, before session.commit().
|
||
- No write on any other handler (login, logout, register, refresh, etc.).
|
||
- `import time` is present at module level of api/auth.py.
|
||
</behavior>
|
||
<action>
|
||
Add `import time` to api/auth.py after the existing `import uuid` line (line 26) — alphabetical placement in stdlib imports.
|
||
|
||
In change_password (line 455): locate the `await session.commit()` line at end of handler. Insert immediately above it:
|
||
`await request.app.state.redis.set(f"user_nbf:{current_user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)`
|
||
|
||
In enable_totp (line 552): locate `await session.commit()` at end of handler. `redis_client` is already assigned near the top of this function (`redis_client = request.app.state.redis`). Insert immediately above session.commit():
|
||
`await redis_client.set(f"user_nbf:{current_user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)`
|
||
|
||
In disable_totp (line 607): locate `await session.commit()` at end of handler. Insert immediately above it:
|
||
`await request.app.state.redis.set(f"user_nbf:{current_user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)`
|
||
|
||
Do NOT add `settings` import (already present). Do NOT extract a TTL constant — the derived form is readable and stays synchronized with config automatically.
|
||
</action>
|
||
<verify>
|
||
<automated>cd backend && pytest tests/test_auth_api.py -v -k "nbf or user_nbf"</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- `grep -E "^import time" backend/api/auth.py` returns 1 match
|
||
- `grep -c "user_nbf" backend/api/auth.py` returns >= 3 (one per handler)
|
||
- `grep -c "access_token_expire_minutes \* 60" backend/api/auth.py` returns >= 3
|
||
- `cd backend && pytest tests/test_auth_api.py -v -k "nbf or user_nbf"` exits 0; at least 3 tests report PASSED (was XFAIL)
|
||
- `cd backend && pytest tests/test_auth_api.py -v` exits 0 with zero failures (zero regressions to existing change_password + totp tests)
|
||
- The three NBF-write stubs (test_change_password_writes_user_nbf_to_redis, test_enable_totp_writes_user_nbf_to_redis, test_disable_totp_writes_user_nbf_to_redis) are promoted from XFAIL to PASSED in this task — update the test bodies, removing the xfail decorators and replacing placeholder bodies with real assertions (see Task 2 of Plan 02 for the assertion pattern: `await app.state.redis.get(f"user_nbf:{user_id}")` returns a non-None bytes-like value whose `int(decode())` > 0)
|
||
</acceptance_criteria>
|
||
<done>import time added; user_nbf written in all three auth security-event handlers; stubs promoted to PASSED.</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 2: Add import time + user_nbf write to api/admin.py deactivation handler</name>
|
||
<files>backend/api/admin.py</files>
|
||
<read_first>
|
||
- backend/api/admin.py lines 24–50 (module-level imports — add `import time` here)
|
||
- backend/api/admin.py lines 326–380 (PATCH /api/admin/users/{id}/status handler — locate the `if not body.is_active:` block at ~line 351; the write goes inside this block, after revoke_all_refresh_tokens)
|
||
- backend/config.py (confirm access_token_expire_minutes field name)
|
||
- .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-RESEARCH.md "Pattern 4" (lines 239–252) and "Anti-Patterns" ("Do not write user_nbf for successful activation")
|
||
- backend/tests/test_admin_api.py (two Wave 0 stubs: test_deactivate_user_writes_user_nbf_to_redis + test_activate_user_does_not_write_user_nbf — promote both in this task)
|
||
</read_first>
|
||
<behavior>
|
||
- When PATCH /api/admin/users/{id}/status is called with `is_active=false`: after revoke_all_refresh_tokens, user_nbf:{user.id} is written to Redis with TTL=access_token_expire_minutes*60.
|
||
- When called with `is_active=true` (reactivation): user_nbf is NOT written (the negative guard test must pass).
|
||
- `import time` is present at module level of api/admin.py.
|
||
- `settings` must be in scope for the TTL expression — confirm it is already imported in admin.py.
|
||
</behavior>
|
||
<action>
|
||
Add `import time` to api/admin.py after existing stdlib imports — alphabetical placement alongside `import uuid` (line 26).
|
||
|
||
Locate the deactivation block in the status handler (around line 351):
|
||
```
|
||
if not body.is_active:
|
||
# Revoke all refresh tokens on deactivation
|
||
await revoke_all_refresh_tokens(session, user.id)
|
||
```
|
||
Add one line immediately after `await revoke_all_refresh_tokens(session, user.id)`, still inside the `if not body.is_active:` block:
|
||
`await request.app.state.redis.set(f"user_nbf:{user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60)`
|
||
|
||
Verify `settings` is already imported in admin.py (it should be — grep for `from config import settings`). If not present, add `from config import settings` to the imports section.
|
||
|
||
Promote the two Wave 0 stubs in backend/tests/test_admin_api.py:
|
||
- test_deactivate_user_writes_user_nbf_to_redis: remove xfail decorator; replace placeholder body with real assertions: PATCH is_active=false → response 200; then `await client._transport.app.state.redis.get(f"user_nbf:{user.id}")` returns non-None bytes whose `int(decode())` > 0.
|
||
- test_activate_user_does_not_write_user_nbf: remove xfail decorator; replace body: PATCH is_active=true (on a deactivated user); then assert `await app.state.redis.get(f"user_nbf:{user.id}")` is None (the key should not exist for a reactivation event).
|
||
</action>
|
||
<verify>
|
||
<automated>cd backend && pytest tests/test_admin_api.py -v</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- `grep -E "^import time" backend/api/admin.py` returns 1 match
|
||
- `grep -c "user_nbf" backend/api/admin.py` returns >= 1
|
||
- `grep -c "access_token_expire_minutes \* 60" backend/api/admin.py` returns >= 1
|
||
- The user_nbf write line is inside the `if not body.is_active:` block (not at module scope or outside the conditional) — verify with grep -n and line-number inspection
|
||
- `cd backend && pytest tests/test_admin_api.py -v` exits 0 with zero failures
|
||
- test_deactivate_user_writes_user_nbf_to_redis reports PASSED (promoted from XFAIL)
|
||
- test_activate_user_does_not_write_user_nbf reports PASSED (promoted from XFAIL; Redis key must be None for activation path)
|
||
- All existing admin tests (test_deactivate_user, test_reactivate_user, etc.) still PASSED
|
||
</acceptance_criteria>
|
||
<done>import time added; user_nbf written in admin deactivation handler (conditional on is_active=False only); both stubs promoted to PASSED; zero admin test regressions.</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 3: Full regression run — verify zero new failures across entire test suite</name>
|
||
<files></files>
|
||
<read_first>
|
||
- .planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-VALIDATION.md (Validation Sign-Off checklist)
|
||
</read_first>
|
||
<behavior>
|
||
- Full pytest suite passes with zero new failures vs the Phase 7.1 baseline.
|
||
- All 9 Phase 7.2 behaviors from VALIDATION.md report green: jti-claim PASSED, test-fakeredis PASSED, nbf-write × 4 PASSED, nbf-check × 2 PASSED, nbf-fail-open PASSED.
|
||
- No previously-XFAIL stubs remain (all were promoted in Plans 02 and 03).
|
||
</behavior>
|
||
<action>
|
||
Run the full backend test suite. Review output for any unexpected failures. If any test introduced by this plan fails, investigate and fix before marking this task done.
|
||
</action>
|
||
<verify>
|
||
<automated>cd backend && pytest -v 2>&1 | tail -20</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- `cd backend && pytest -v` exits 0 with zero failures
|
||
- Output contains no FAILED lines
|
||
- `cd backend && pytest tests/test_auth_deps.py tests/test_auth_api.py tests/test_admin_api.py tests/test_task2_auth_service.py -v -k "jti or nbf or user_nbf or failopen"` exits 0 with at least 9 PASSED results covering all 9 VALIDATION.md behaviors
|
||
- `grep -rn "user_nbf" backend/api/auth.py backend/api/admin.py backend/deps/auth.py` returns >= 4 lines (3 writers in api/auth.py + 1 writer in api/admin.py + 1 reader in deps/auth.py)
|
||
</acceptance_criteria>
|
||
<done>Full suite green; all Phase 7.2 behaviors verified; no regressions.</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<threat_model>
|
||
## Trust Boundaries
|
||
|
||
| Boundary | Description |
|
||
|----------|-------------|
|
||
| client → API (security events) | Authenticated user or admin triggers password change / TOTP / deactivation; write must fire unconditionally before commit |
|
||
| API → Redis (app.state.redis) | Trusted local network; write is best-effort — if Redis is down, the session.commit() still completes (no rollback) |
|
||
|
||
## STRIDE Threat Register
|
||
|
||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||
|-----------|----------|-----------|-------------|-----------------|
|
||
| T-7.2-01 | Elevation of Privilege | All four security-event handlers | mitigate | user_nbf:{user_id} written with TTL=access_token_expire_minutes*60 before session.commit() on change_password, enable_totp, disable_totp, admin deactivation. Plan 02's get_current_user check fires on the next request from any pre-event token. |
|
||
| T-7.2-ACT | Elevation of Privilege | Admin activation path (is_active=True) | mitigate | Write is strictly inside `if not body.is_active:` block. Reactivation does NOT write user_nbf — a freshly reactivated user's first token is issued after reactivation and will have iat > any prior nbf. Verified by test_activate_user_does_not_write_user_nbf. |
|
||
| T-7.2-TTL | Elevation of Privilege | TTL mismatch between write sites | mitigate | All four sites use `settings.access_token_expire_minutes * 60` (not hardcoded 900) — stays synchronized if TTL changes in config. |
|
||
| T-7.2-W2-SC | Tampering | npm/pip/cargo installs | n/a | No new packages — only `import time` (stdlib) added. RESEARCH.md "Package Legitimacy Audit" is intentionally blank for this phase. |
|
||
</threat_model>
|
||
|
||
<verification>
|
||
- `cd backend && pytest -v` — zero failures; full suite green
|
||
- `grep -c "user_nbf" backend/api/auth.py` returns >= 3 (one per security-event handler)
|
||
- `grep -c "user_nbf" backend/api/admin.py` returns >= 1 (deactivation only)
|
||
- `grep -E "^import time" backend/api/auth.py backend/api/admin.py` returns 2 lines
|
||
- All 5 NBF-write/check/fail-open stubs from Plan 01 that were not promoted in Plan 02 now PASSED
|
||
- `cd backend && bandit -r backend/ --severity-level high` — zero HIGH severity findings introduced by this plan
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
- All four security-event handlers write user_nbf:{user_id} to Redis with correct TTL
|
||
- Reactivation path does NOT write user_nbf
|
||
- TTL expression uses settings.access_token_expire_minutes * 60 in all four write sites
|
||
- Complete Phase 7.2 test matrix (9 behaviors from VALIDATION.md) reports PASSED
|
||
- Zero regressions: Phase 7.1's 373-test baseline unchanged
|
||
</success_criteria>
|
||
|
||
<output>
|
||
Create `.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-03-SUMMARY.md` when done.
|
||
|
||
Required fields in SUMMARY: artifacts (api/auth.py + api/admin.py), patterns_established (user_nbf write pattern; conditional deactivation-only write; TTL derived from settings), patterns_to_avoid (do NOT write on activation; do NOT hardcode 900), provides (full Phase 7.2 revocation pipeline complete — jti issued + NBF checked + user_nbf written on all four security events).
|
||
</output>
|