feat: user attribution on notes — track who creates and edits #272
No reviewers
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo_admin/pal-e-api!272
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "268-user-attribution"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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_nameon notes andrevised_byon 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— AddedUserContextdataclass andget_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 toNoteOutandNoteSummaryresponse schemas.src/pal_e_docs/routes/notes.py— Wiredget_current_userintocreate_note,update_note, andcreate_note_from_template. Addedcreated_by_subquery filter tolist_notes. Auto-populatesrevised_byon 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 formatandruff checkcleanReview Checklist
Related Notes
Related
Closes #268
PR #272 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Pydantic
auth.py -- JWT decode without signature verification
The
_decode_jwt_payloadfunction 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. Thepreferred_usernamefallback fornameis a good Keycloak-aware touch.models.py -- 4 new nullable columns
All
String(500), all nullable. Consistent with existing column patterns. Placed logically beforecreated_at/updated_at. No issues.schemas.py -- NoteOut and NoteSummary
Both schemas updated symmetrically. Fields are
Optional[str]withNonedefaults. Correct.routes/notes.py -- Attribution wiring
create_note: Sets all 4 attribution fields on create. Correct thatupdated_byis also set on create (initial creator = initial updater).update_note: Only setsupdated_by_subandupdated_by_name. Does not overwritecreated_byfields. Correct.create_note_from_template: Passescurrent_userthrough tocreate_note. Correct.revised_bylogic: JWT name takes precedence, falls back tobody.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_columnin upgrade, 4drop_columnin downgrade (reverse order). However,down_revision = "u1p2q3r4s5t6"does not exist on main. The current head on main iss9n0o1p2q3r4. The PR body states this depends on dual auth (#267, closed by PR #269). If PR #269 introduced intermediate migrationst0...andu1..., the chain is valid. If not, this migration will fail onalembic upgrade head. This needs verification before merge.Tests -- test_user_attribution.py
9 tests across 4 test classes covering:
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
TestClientwithout auth headers forupdate_note, which works because the test app apparently does not requireget_is_authenticated(no API key configured in test env, soget_is_authenticatedreturns True). This is fine -- the test is specifically testing theget_current_userdependency, not the auth gate.BLOCKERS
None.
created_by_subfilter is a simple string equality query parameter, no injection risk (SQLAlchemy parameterized queries).revised_byternary appears in two places (create and update). This is a one-liner with identical logic. Acceptable -- extracting a helper would over-engineer it.NITS
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 IDst0...andu1..., this migration will break the chain. Dev should confirm the chain is intact before merge.Fabricated revision ID: The revision ID
v2q3r4s5t6u7follows the repo's alphabetical-incrementing pattern but looks hand-crafted rather than auto-generated byalembic revision --autogenerate. This is fine functionally but worth noting.Hardcoded
Create Date: The migration hasCreate Date: 2026-04-12 00:00:00.000000which is clearly a placeholder. Cosmetic only -- Alembic does not use this field.No index on
created_by_sub: Thelist_notesendpoint now filters oncreated_by_sub. If this column will be queried frequently, an index would improve performance. Not urgent at current scale.rolesfield extracted but unused:UserContext.rolesis populated fromrealm_access.rolesbut 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
268-user-attributionmatches issue #268PROCESS OBSERVATIONS
mergeable: falsewhich 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.
9004b29b78tof1ff8a8155