feat: private notes enforcement (is_public filtering + API key auth) #179
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!179
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "178-feat-private-notes-enforcement-is-public"
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
Add API key authentication via
X-PaleDocs-Tokenheader with timing-safe comparison (secrets.compare_digest). WhenPALDOCS_PAL_E_DOCS_API_KEYis configured, unauthenticated requests only seeis_public=truenotes across all read endpoints. Fully backwards compatible -- no API key configured means all notes visible (existing behavior preserved).Changes
src/pal_e_docs/config.py-- Addedpal_e_docs_api_keysetting (read fromPALDOCS_PAL_E_DOCS_API_KEYenv var)src/pal_e_docs/auth.py-- Addedget_is_authenticated()FastAPI dependency that checksX-PaleDocs-Tokenheader against configured API keysrc/pal_e_docs/routes/notes.py-- Added auth filtering to all read endpoints:list_notes()-- filtersNote.is_public == Truefor unauthenticated requestsget_note()-- returns 404 for private notes when unauthenticatedsearch_notes()-- passesis_authenticatedthrough to_keyword_search()and_semantic_search_notes()semantic_search()-- addsn.is_public = trueWHERE clause for unauthenticated requests_build_filter_clauses()-- acceptsis_authenticatedparam, adds public-only filter when falsen.is_publicfor schema populationsrc/pal_e_docs/schemas.py-- Addedis_public: boolfield toNoteSearchResultandSemanticSearchResultsrc/pal_e_docs/services/search.py-- Addedis_public: boolfield toRankedItemdataclass for hybrid search passthroughtests/test_private_notes.py-- 13 tests covering all auth scenariosTest Plan
pytest tests/test_private_notes.py -v-- 13/13 passpytest tests/ -v-- 536/536 pass (no regressions)ruff format+ruff check-- cleanPALDOCS_PAL_E_DOCS_API_KEY=secretenv var, confirm unauthenticated/notesexcludes private notes, confirmX-PaleDocs-Token: secretheader restores full visibilityReview Checklist
secrets.compare_digestfor timing-safe comparisonn.is_publicfor schema populationRelated
PR #179 Review
BLOCKERS
B1: Private note content leaks via sub-resource endpoints (blocks, TOC, compiled, revisions, links)
The following endpoints accept a note
slugand return content without checkingis_authenticatedoris_public. An unauthenticated user who knows (or guesses) a private note's slug can retrieve its full content:GET /notes/{slug}/revisionsroutes/notes.pyhtml_contentof every revisionGET /notes/{slug}/tocroutes/blocks.pyGET /notes/{slug}/compiledroutes/blocks.pyGET /notes/{slug}/blocksroutes/blocks.pyGET /notes/{slug}/blocks/{anchor_id}routes/blocks.pyGET /notes/{slug}/linksroutes/links.pyEach of these needs the same
is_authenticateddependency check. The cleanest fix is to extract a shared helper (e.g.,_get_note_or_404_with_auth(db, slug, is_authenticated)) that bothnotes.pyandblocks.pycan call, returning 404 for private notes when unauthenticated.B2:
GET /projects/{slug}/notesleaks private notesroutes/projects.pyline 117 --list_project_notes()returns all notes in a project without filteringis_public. An unauthenticated user can enumerate private notes (title, slug, tags, metadata) via this endpoint, completely bypassing the filtering added tolist_notes.This endpoint needs the
get_is_authenticateddependency and anis_publicfilter, identical to what was done forlist_notes.B3: Write endpoints (update, delete) unprotected for private notes
PUT /notes/{slug}andDELETE /notes/{slug}have no auth check. The PR description explicitly states "Write endpoints (create, update, delete) are NOT filtered" -- but this creates a security hole: an unauthenticated user who knows a private note's slug can:is_publicfromfalsetotrue(escalating visibility)This is a design decision that should be explicitly discussed. At minimum,
update_noteshould not allow changingis_publicfromfalsetotruewithout authentication, as that would be a privilege escalation.NITS
N1: Hybrid search
is_publicmerge gap inrrf_fuseIn
services/search.pyrrf_fuse()lines 76-81, when a document appears in both keyword and semantic results, onlysemantic_rankis merged into the existing keyword item. Theis_publicvalue from the semantic result is silently discarded. This works correctly today because both lists are pre-filtered identically, but it is fragile -- if filtering ever diverges,is_publiccould be wrong in the fused result.N2: Tests use SQLite but auth filtering uses raw SQL with
n.is_public = trueThe
_build_filter_clausesfunction appendsn.is_public = true(Postgres boolean literal). SQLite stores booleans as 0/1 integers, so this clause may not match as expected in the test database. The tests for search endpoints (keyword, semantic, hybrid) would need Postgres to exercise the auth filtering path, and they are not included in this PR. The ORM-based filtering inlist_notes(usingNote.is_public == True) works correctly on both SQLite and Postgres because SQLAlchemy handles the translation.This is not a regression since the search tests already require Postgres (
501on SQLite), but it means theis_publicfiltering in_build_filter_clausesandsemantic_searchhas zero test coverage in this PR.N3: No test for
delete_noteauth behaviorThe PR tests
create_noteandupdate_notefor write-endpoint passthrough but omitsdelete_note. For completeness, a test confirming delete behavior (whether protected or not) would document the design intent.N4:
_create_notescalled inside patch context creates notes via API without authIn tests like
test_list_notes_unauthenticated_excludes_private,_create_notes(client)is called inside thepatchcontext where the API key is configured. ThePOST /notescreate endpoint has no auth dependency, so this works, but it means the test is silently relying on write endpoints being unprotected. If B3 is fixed (write endpoints require auth), these tests will need updating.SOP COMPLIANCE
178-feat-private-notes-enforcement-is-publicreferences issue #178)Closes #178but does not referenceplan-pal-e-docsPhase F6 by slugVERDICT: NOT APPROVED
The auth filtering on the primary note endpoints (list, get, search, semantic-search) is well-implemented with correct use of
secrets.compare_digest, 404 for private notes (no information leakage about existence), and proper backwards compatibility. The test suite covers the core scenarios well.However, the sub-resource endpoints (B1), project notes listing (B2), and unprotected write endpoints (B3) create multiple paths to bypass the filtering. B1 and B2 are data leaks that directly undermine the feature's security goal. These must be addressed before merge.
Recommended fix approach: Extract a reusable
_get_note_or_404_with_auth(db, slug, is_authenticated)helper that bothnotes.pyandblocks.pycan share, and add theget_is_authenticateddependency tolist_project_notes,list_revisions, and all block/link read endpoints.B1: Add is_authenticated checks to 6 sub-resource endpoints (revisions, toc, compiled, blocks, blocks/{anchor_id}, links) that previously returned private note content without auth verification. B2: Filter private notes from GET /projects/{slug}/notes when unauthenticated. B3: Require authentication for PUT /notes/{slug} and DELETE /notes/{slug} -- unauthenticated requests now return 401. Backwards compatible when no API key is configured (is_authenticated returns True). Closes #178 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>