feat: user attribution on notes — track who creates and edits #272

Merged
forgejo_admin merged 1 commit from 268-user-attribution into main 2026-04-13 15:12:50 +00:00

Summary

Adds user attribution tracking to notes. When a JWT Bearer token is present, the API auto-populates created_by_sub, created_by_name, updated_by_sub, updated_by_name on notes and revised_by on revisions from the token claims. API key callers (MCP agents) get NULL attribution — no crash, fully backwards compatible.

Changes

  • src/pal_e_docs/auth.py — Added UserContext dataclass and get_current_user() FastAPI dependency that extracts identity from JWT Bearer token payload. Returns None for non-JWT callers.
  • src/pal_e_docs/models.py — Added 4 nullable columns to Note: created_by_sub, created_by_name, updated_by_sub, updated_by_name (all String(500)).
  • src/pal_e_docs/schemas.py — Added attribution fields to NoteOut and NoteSummary response schemas.
  • src/pal_e_docs/routes/notes.py — Wired get_current_user into create_note, update_note, and create_note_from_template. Added created_by_sub query filter to list_notes. Auto-populates revised_by on NoteRevision from JWT name with fallback to body.
  • alembic/versions/v2q3r4s5t6u7_add_note_attribution_columns.py — Migration adding 4 nullable columns to notes table.
  • tests/test_user_attribution.py — 9 tests covering all acceptance criteria.

Test Plan

  • pytest tests/ -v — 694 tests pass (including 9 new attribution tests)
  • ruff format and ruff check clean
  • Tests verify: JWT create attribution, no-JWT null attribution, update attribution, created_by_sub filter, revision revised_by auto-populate, body.revised_by fallback

Review Checklist

  • All 694 tests pass
  • ruff format and ruff check clean
  • All new columns nullable (backwards compatible)
  • API key callers get NULL attribution (no crash)
  • Migration follows existing naming convention
  • No unrelated changes
  • Depends on dual auth middleware (#267) for production JWT flow; this PR adds the minimal UserContext extraction independently

Closes #268

## Summary Adds user attribution tracking to notes. When a JWT Bearer token is present, the API auto-populates `created_by_sub`, `created_by_name`, `updated_by_sub`, `updated_by_name` on notes and `revised_by` on revisions from the token claims. API key callers (MCP agents) get NULL attribution — no crash, fully backwards compatible. ## Changes - `src/pal_e_docs/auth.py` — Added `UserContext` dataclass and `get_current_user()` FastAPI dependency that extracts identity from JWT Bearer token payload. Returns None for non-JWT callers. - `src/pal_e_docs/models.py` — Added 4 nullable columns to Note: `created_by_sub`, `created_by_name`, `updated_by_sub`, `updated_by_name` (all String(500)). - `src/pal_e_docs/schemas.py` — Added attribution fields to `NoteOut` and `NoteSummary` response schemas. - `src/pal_e_docs/routes/notes.py` — Wired `get_current_user` into `create_note`, `update_note`, and `create_note_from_template`. Added `created_by_sub` query filter to `list_notes`. Auto-populates `revised_by` on NoteRevision from JWT name with fallback to body. - `alembic/versions/v2q3r4s5t6u7_add_note_attribution_columns.py` — Migration adding 4 nullable columns to notes table. - `tests/test_user_attribution.py` — 9 tests covering all acceptance criteria. ## Test Plan - `pytest tests/ -v` — 694 tests pass (including 9 new attribution tests) - `ruff format` and `ruff check` clean - Tests verify: JWT create attribution, no-JWT null attribution, update attribution, created_by_sub filter, revision revised_by auto-populate, body.revised_by fallback ## Review Checklist - [x] All 694 tests pass - [x] ruff format and ruff check clean - [x] All new columns nullable (backwards compatible) - [x] API key callers get NULL attribution (no crash) - [x] Migration follows existing naming convention - [x] No unrelated changes ## Related Notes - Depends on dual auth middleware (#267) for production JWT flow; this PR adds the minimal UserContext extraction independently ## Related Closes #268
Add created_by_sub, created_by_name, updated_by_sub, updated_by_name
columns to Note model. Auto-populate from JWT Bearer token claims on
create/update. API key callers get NULL attribution (no crash).

Add UserContext dataclass and get_current_user() dependency to auth.py
for extracting identity from JWT payload. Add created_by_sub query
filter to GET /notes. Auto-populate NoteRevision.revised_by from JWT
name when present, falling back to body.revised_by.

Closes #268

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

PR #272 Review

DOMAIN REVIEW

Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Pydantic

auth.py -- JWT decode without signature verification

The _decode_jwt_payload function explicitly skips signature verification, delegating that to the API gateway/ingress layer. This is documented in the docstring and is an acceptable pattern when the ingress (oauth2-proxy, Tailscale funnel + Keycloak) handles verification. The function is used only for attribution (not authorization), so a forged JWT would only affect who gets credited, not what they can access. This is a reasonable tradeoff given the architecture.

auth.py -- UserContext dataclass

Clean design. Uses dataclass (not Pydantic) which is appropriate since this is internal state, not a request/response schema. Fields are well-typed with sensible defaults. The preferred_username fallback for name is a good Keycloak-aware touch.

models.py -- 4 new nullable columns

All String(500), all nullable. Consistent with existing column patterns. Placed logically before created_at/updated_at. No issues.

schemas.py -- NoteOut and NoteSummary

Both schemas updated symmetrically. Fields are Optional[str] with None defaults. Correct.

routes/notes.py -- Attribution wiring

  • create_note: Sets all 4 attribution fields on create. Correct that updated_by is also set on create (initial creator = initial updater).
  • update_note: Only sets updated_by_sub and updated_by_name. Does not overwrite created_by fields. Correct.
  • create_note_from_template: Passes current_user through to create_note. Correct.
  • revised_by logic: JWT name takes precedence, falls back to body.revised_by. Applied in both create and update paths. DRY concern is minor since the logic is a one-liner ternary in two places.

routes/notes.py -- created_by_sub filter

Added as a query parameter to list_notes. Direct string equality filter on a nullable column. Works correctly -- NULL values will not match any filter value.

Migration -- v2q3r4s5t6u7

The migration itself is clean: 4 add_column in upgrade, 4 drop_column in downgrade (reverse order). However, down_revision = "u1p2q3r4s5t6" does not exist on main. The current head on main is s9n0o1p2q3r4. The PR body states this depends on dual auth (#267, closed by PR #269). If PR #269 introduced intermediate migrations t0... and u1..., the chain is valid. If not, this migration will fail on alembic upgrade head. This needs verification before merge.

Tests -- test_user_attribution.py

9 tests across 4 test classes covering:

  • Create with JWT -> attribution populated
  • Create without JWT -> NULL attribution
  • Update with JWT -> updated_by populated, created_by unchanged
  • Update without JWT -> updated_by stays NULL
  • Filter by created_by_sub -> correct filtering
  • Filter by nonexistent sub -> empty result
  • Revision revised_by from JWT
  • Revision revised_by fallback to body
  • Update revision revised_by from JWT

Coverage is thorough. Happy path, null path, and fallback path all tested. Edge case of update-without-JWT-after-create-without-JWT is also covered.

One observation: tests use TestClient without auth headers for update_note, which works because the test app apparently does not require get_is_authenticated (no API key configured in test env, so get_is_authenticated returns True). This is fine -- the test is specifically testing the get_current_user dependency, not the auth gate.

BLOCKERS

None.

  • Test coverage: 9 new tests covering all acceptance criteria. Not a blocker.
  • Input validation: created_by_sub filter is a simple string equality query parameter, no injection risk (SQLAlchemy parameterized queries).
  • Secrets: No hardcoded credentials. JWT decode is claim extraction only.
  • DRY: The revised_by ternary appears in two places (create and update). This is a one-liner with identical logic. Acceptable -- extracting a helper would over-engineer it.

NITS

  1. Migration chain verification needed: down_revision = "u1p2q3r4s5t6" must be verified to exist after PR #269 is merged. If the dual auth PR did not add migrations with IDs t0... and u1..., this migration will break the chain. Dev should confirm the chain is intact before merge.

  2. Fabricated revision ID: The revision ID v2q3r4s5t6u7 follows the repo's alphabetical-incrementing pattern but looks hand-crafted rather than auto-generated by alembic revision --autogenerate. This is fine functionally but worth noting.

  3. Hardcoded Create Date: The migration has Create Date: 2026-04-12 00:00:00.000000 which is clearly a placeholder. Cosmetic only -- Alembic does not use this field.

  4. No index on created_by_sub: The list_notes endpoint now filters on created_by_sub. If this column will be queried frequently, an index would improve performance. Not urgent at current scale.

  5. roles field extracted but unused: UserContext.roles is populated from realm_access.roles but never referenced in this PR. This is forward-looking (for #250 permission filtering), which is fine, but the unused field could be documented as such.

SOP COMPLIANCE

  • Branch named after issue: 268-user-attribution matches issue #268
  • PR body follows template: Summary, Changes, Test Plan, Related sections all present
  • Related references plan slug: No plan slug referenced (PR states "No plan slug" which is consistent with the dispatch)
  • No secrets committed: Clean
  • No scope creep: All changes directly related to user attribution
  • Commit messages: PR title is descriptive

PROCESS OBSERVATIONS

  • Deployment frequency: Clean, focused PR. 6 files changed, 347 additions, 6 deletions. Good scope.
  • Change failure risk: Low. All new columns nullable. No breaking changes to existing API contracts. Backwards compatible with API-key-only callers.
  • Dependency: PR body correctly notes dependency on #267 (dual auth). The migration chain dependency should be verified before merge.
  • Mergeable: PR shows mergeable: false which may indicate a rebase is needed against main (possibly after PR #269 merged).

VERDICT: APPROVED

Clean implementation with thorough test coverage. The migration chain dependency on PR #269 needs verification before merge, but the code itself is correct and well-structured. Rebase against main to resolve the mergeable conflict.

## PR #272 Review ### DOMAIN REVIEW **Tech stack**: Python / FastAPI / SQLAlchemy / Alembic / Pydantic **auth.py -- JWT decode without signature verification** The `_decode_jwt_payload` function explicitly skips signature verification, delegating that to the API gateway/ingress layer. This is documented in the docstring and is an acceptable pattern when the ingress (oauth2-proxy, Tailscale funnel + Keycloak) handles verification. The function is used only for attribution (not authorization), so a forged JWT would only affect who gets credited, not what they can access. This is a reasonable tradeoff given the architecture. **auth.py -- UserContext dataclass** Clean design. Uses `dataclass` (not Pydantic) which is appropriate since this is internal state, not a request/response schema. Fields are well-typed with sensible defaults. The `preferred_username` fallback for `name` is a good Keycloak-aware touch. **models.py -- 4 new nullable columns** All `String(500)`, all nullable. Consistent with existing column patterns. Placed logically before `created_at`/`updated_at`. No issues. **schemas.py -- NoteOut and NoteSummary** Both schemas updated symmetrically. Fields are `Optional[str]` with `None` defaults. Correct. **routes/notes.py -- Attribution wiring** - `create_note`: Sets all 4 attribution fields on create. Correct that `updated_by` is also set on create (initial creator = initial updater). - `update_note`: Only sets `updated_by_sub` and `updated_by_name`. Does not overwrite `created_by` fields. Correct. - `create_note_from_template`: Passes `current_user` through to `create_note`. Correct. - `revised_by` logic: JWT name takes precedence, falls back to `body.revised_by`. Applied in both create and update paths. DRY concern is minor since the logic is a one-liner ternary in two places. **routes/notes.py -- created_by_sub filter** Added as a query parameter to `list_notes`. Direct string equality filter on a nullable column. Works correctly -- NULL values will not match any filter value. **Migration -- v2q3r4s5t6u7** The migration itself is clean: 4 `add_column` in upgrade, 4 `drop_column` in downgrade (reverse order). However, `down_revision = "u1p2q3r4s5t6"` does not exist on main. The current head on main is `s9n0o1p2q3r4`. The PR body states this depends on dual auth (#267, closed by PR #269). If PR #269 introduced intermediate migrations `t0...` and `u1...`, the chain is valid. If not, this migration will fail on `alembic upgrade head`. **This needs verification before merge.** **Tests -- test_user_attribution.py** 9 tests across 4 test classes covering: - Create with JWT -> attribution populated - Create without JWT -> NULL attribution - Update with JWT -> updated_by populated, created_by unchanged - Update without JWT -> updated_by stays NULL - Filter by created_by_sub -> correct filtering - Filter by nonexistent sub -> empty result - Revision revised_by from JWT - Revision revised_by fallback to body - Update revision revised_by from JWT Coverage is thorough. Happy path, null path, and fallback path all tested. Edge case of update-without-JWT-after-create-without-JWT is also covered. One observation: tests use `TestClient` without auth headers for `update_note`, which works because the test app apparently does not require `get_is_authenticated` (no API key configured in test env, so `get_is_authenticated` returns True). This is fine -- the test is specifically testing the `get_current_user` dependency, not the auth gate. ### BLOCKERS None. - Test coverage: 9 new tests covering all acceptance criteria. Not a blocker. - Input validation: `created_by_sub` filter is a simple string equality query parameter, no injection risk (SQLAlchemy parameterized queries). - Secrets: No hardcoded credentials. JWT decode is claim extraction only. - DRY: The `revised_by` ternary appears in two places (create and update). This is a one-liner with identical logic. Acceptable -- extracting a helper would over-engineer it. ### NITS 1. **Migration chain verification needed**: `down_revision = "u1p2q3r4s5t6"` must be verified to exist after PR #269 is merged. If the dual auth PR did not add migrations with IDs `t0...` and `u1...`, this migration will break the chain. Dev should confirm the chain is intact before merge. 2. **Fabricated revision ID**: The revision ID `v2q3r4s5t6u7` follows the repo's alphabetical-incrementing pattern but looks hand-crafted rather than auto-generated by `alembic revision --autogenerate`. This is fine functionally but worth noting. 3. **Hardcoded `Create Date`**: The migration has `Create Date: 2026-04-12 00:00:00.000000` which is clearly a placeholder. Cosmetic only -- Alembic does not use this field. 4. **No index on `created_by_sub`**: The `list_notes` endpoint now filters on `created_by_sub`. If this column will be queried frequently, an index would improve performance. Not urgent at current scale. 5. **`roles` field extracted but unused**: `UserContext.roles` is populated from `realm_access.roles` but never referenced in this PR. This is forward-looking (for #250 permission filtering), which is fine, but the unused field could be documented as such. ### SOP COMPLIANCE - [x] Branch named after issue: `268-user-attribution` matches issue #268 - [x] PR body follows template: Summary, Changes, Test Plan, Related sections all present - [ ] Related references plan slug: No plan slug referenced (PR states "No plan slug" which is consistent with the dispatch) - [x] No secrets committed: Clean - [x] No scope creep: All changes directly related to user attribution - [x] Commit messages: PR title is descriptive ### PROCESS OBSERVATIONS - **Deployment frequency**: Clean, focused PR. 6 files changed, 347 additions, 6 deletions. Good scope. - **Change failure risk**: Low. All new columns nullable. No breaking changes to existing API contracts. Backwards compatible with API-key-only callers. - **Dependency**: PR body correctly notes dependency on #267 (dual auth). The migration chain dependency should be verified before merge. - **Mergeable**: PR shows `mergeable: false` which may indicate a rebase is needed against main (possibly after PR #269 merged). ### VERDICT: APPROVED Clean implementation with thorough test coverage. The migration chain dependency on PR #269 needs verification before merge, but the code itself is correct and well-structured. Rebase against main to resolve the mergeable conflict.
forgejo_admin force-pushed 268-user-attribution from 9004b29b78 to f1ff8a8155 2026-04-13 14:59:26 +00:00 Compare
forgejo_admin deleted branch 268-user-attribution 2026-04-13 15:12:50 +00:00
Sign in to join this conversation.
No description provided.