fix: add is_public filtering to /projects and /boards endpoints #185

Merged
forgejo_admin merged 2 commits from 184-projects-boards-is-public-filtering into main 2026-03-17 00:12:17 +00:00

Summary

Unauthenticated requests to /projects and /boards endpoints now respect is_public filtering, matching the pattern established in F6 (PR #179) for notes. Previously, all projects and boards were returned regardless of authentication, exposing private projects like "Private" and "Remember" to public visitors.

Changes

  • src/pal_e_docs/routes/projects.py -- Added get_is_authenticated dependency to list_projects (filters is_public=false projects) and get_project (returns 404 for private projects when unauthenticated)
  • src/pal_e_docs/routes/boards.py -- Added get_is_authenticated dependency to list_boards (joins to Project and filters by is_public) and get_board (returns 404 when parent project is private and unauthenticated)
  • tests/test_private_projects_boards.py -- 22 new tests covering: backwards compatibility (no API key), unauthenticated filtering, authenticated access, wrong token, edge cases (all private, nonexistent slugs)

Test Plan

  • pytest tests/test_private_projects_boards.py -v -- 22 passed
  • pytest tests/ -v -- 599 passed (full suite, no regressions)
  • ruff format and ruff check clean

Review Checklist

  • Code follows existing patterns (mirrors routes/notes.py auth filtering)
  • Tests added for all acceptance criteria
  • No unrelated changes
  • Linting passes (ruff format + ruff check)
  • Plan: phase-pal-e-docs-f14-public-readiness
  • Closes #184
  • Foundation: PR #179 (F6 private notes enforcement)
## Summary Unauthenticated requests to `/projects` and `/boards` endpoints now respect `is_public` filtering, matching the pattern established in F6 (PR #179) for notes. Previously, all projects and boards were returned regardless of authentication, exposing private projects like "Private" and "Remember" to public visitors. ## Changes - **`src/pal_e_docs/routes/projects.py`** -- Added `get_is_authenticated` dependency to `list_projects` (filters `is_public=false` projects) and `get_project` (returns 404 for private projects when unauthenticated) - **`src/pal_e_docs/routes/boards.py`** -- Added `get_is_authenticated` dependency to `list_boards` (joins to Project and filters by `is_public`) and `get_board` (returns 404 when parent project is private and unauthenticated) - **`tests/test_private_projects_boards.py`** -- 22 new tests covering: backwards compatibility (no API key), unauthenticated filtering, authenticated access, wrong token, edge cases (all private, nonexistent slugs) ## Test Plan - `pytest tests/test_private_projects_boards.py -v` -- 22 passed - `pytest tests/ -v` -- 599 passed (full suite, no regressions) - `ruff format` and `ruff check` clean ## Review Checklist - [x] Code follows existing patterns (mirrors `routes/notes.py` auth filtering) - [x] Tests added for all acceptance criteria - [x] No unrelated changes - [x] Linting passes (ruff format + ruff check) ## Related - Plan: `phase-pal-e-docs-f14-public-readiness` - Closes #184 - Foundation: PR #179 (F6 private notes enforcement)
fix: add is_public filtering to /projects and /boards endpoints
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
5866167070
Unauthenticated requests now only see public projects and boards
belonging to public projects, matching the pattern established in
F6 (PR #179) for notes. Adds 22 tests covering list, get, auth,
and edge cases for both endpoints.

Closes #184

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: extend is_public filtering to board sub-resource endpoints
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
254e5b0e9c
Self-review caught three additional data leak vectors:
- GET /boards/backlog/items returned items from private project boards
- GET /boards/activity returned items from private project boards
- GET /boards/{slug}/items did not check parent project is_public

All three now join through Board->Project and filter by is_public
when unauthenticated. Adds 7 tests covering items, backlog, and
activity endpoints for both auth states.

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

Self-Review Findings

Issues Found and Fixed (commit 254e5b0)

3 additional data leak vectors in board sub-resource endpoints were not covered by the initial commit:

  1. GET /boards/backlog/items -- Cross-board backlog returned items from private project boards to unauthenticated users. Fixed by joining BoardItem -> Board -> Project and filtering Project.is_public == True.

  2. GET /boards/activity -- Cross-board activity returned items from private project boards. Same join+filter fix.

  3. GET /boards/{slug}/items -- Board items endpoint did not check parent project is_public. Fixed by checking board.project.is_public and returning 404, same pattern as get_board.

Tests Added

7 new tests covering the three endpoints (29 total, up from 22):

  • test_board_items_unauthenticated_private_project_returns_404
  • test_board_items_unauthenticated_public_project_returns_200
  • test_board_items_authenticated_private_project_returns_200
  • test_backlog_items_unauthenticated_excludes_private_project
  • test_backlog_items_authenticated_includes_all
  • test_activity_unauthenticated_excludes_private_project
  • test_activity_authenticated_includes_all

Remaining Known Gap (documented, not a blocker)

GET /projects/{slug}/notes does not check project.is_public -- it only filters notes by note.is_public. This means if you know a private project's slug, you can hit this endpoint and get back public notes from it (empty list if all notes are private). This is acceptable because no private data leaks -- only public notes are returned. Documented in test test_project_notes_endpoint_respects_project_visibility.

Verification

  • pytest tests/ -v -- 606 passed (0 failures)
  • ruff format + ruff check -- clean

VERDICT: APPROVE -- all acceptance criteria met, additional leak vectors caught and fixed during review.

## Self-Review Findings ### Issues Found and Fixed (commit 254e5b0) **3 additional data leak vectors** in board sub-resource endpoints were not covered by the initial commit: 1. **`GET /boards/backlog/items`** -- Cross-board backlog returned items from private project boards to unauthenticated users. Fixed by joining `BoardItem -> Board -> Project` and filtering `Project.is_public == True`. 2. **`GET /boards/activity`** -- Cross-board activity returned items from private project boards. Same join+filter fix. 3. **`GET /boards/{slug}/items`** -- Board items endpoint did not check parent project `is_public`. Fixed by checking `board.project.is_public` and returning 404, same pattern as `get_board`. ### Tests Added 7 new tests covering the three endpoints (29 total, up from 22): - `test_board_items_unauthenticated_private_project_returns_404` - `test_board_items_unauthenticated_public_project_returns_200` - `test_board_items_authenticated_private_project_returns_200` - `test_backlog_items_unauthenticated_excludes_private_project` - `test_backlog_items_authenticated_includes_all` - `test_activity_unauthenticated_excludes_private_project` - `test_activity_authenticated_includes_all` ### Remaining Known Gap (documented, not a blocker) `GET /projects/{slug}/notes` does not check `project.is_public` -- it only filters notes by `note.is_public`. This means if you know a private project's slug, you can hit this endpoint and get back public notes from it (empty list if all notes are private). This is acceptable because no private data leaks -- only public notes are returned. Documented in test `test_project_notes_endpoint_respects_project_visibility`. ### Verification - `pytest tests/ -v` -- 606 passed (0 failures) - `ruff format` + `ruff check` -- clean **VERDICT: APPROVE** -- all acceptance criteria met, additional leak vectors caught and fixed during review.
Author
Owner

PR #185 Review

DOMAIN REVIEW

Tech stack: Python / FastAPI / SQLAlchemy (with SQLite test DB). This is a security-relevant change -- adding is_public filtering to read endpoints for projects and boards, extending the pattern established in PR #179 for notes.

Pattern consistency: The implementation correctly mirrors the approach from routes/notes.py. The get_is_authenticated dependency is injected into all affected read endpoints, and the filtering logic follows the same if not is_authenticated: q = q.filter(Project.is_public == True) pattern. The # noqa: E712 comments are correct -- SQLAlchemy requires == True rather than is True for column comparisons.

Endpoints covered by this PR:

  • GET /projects -- filters by Project.is_public (list)
  • GET /projects/{slug} -- returns 404 for private when unauthenticated (detail)
  • GET /boards -- joins to Project, filters by Project.is_public (list)
  • GET /boards/{slug} -- returns 404 for private project when unauthenticated (detail)
  • GET /boards/{slug}/items -- returns 404 for private project board when unauthenticated
  • GET /boards/backlog/items -- joins Board->Project, filters by Project.is_public
  • GET /boards/activity -- joins Board->Project, filters by Project.is_public

SQLAlchemy join correctness: The joins in list_backlog_items and list_activity correctly chain BoardItem -> Board -> Project using explicit join conditions (BoardItem.board_id == Board.id, Board.project_id == Project.id). The list_boards endpoint joins Board -> Project since Board has a direct FK to Project. All correct.

get_board / list_board_items approach: These use board.project.is_public via lazy-loaded relationship rather than a join. This is fine for single-row lookups -- the lazy load is a single query and avoids unnecessary join complexity for a detail endpoint.

Auth module (auth.py): Uses secrets.compare_digest for timing-safe comparison. Backwards compatibility is preserved: when no API key is configured, get_is_authenticated returns True unconditionally. Solid.

Test coverage (22 tests):

  • Backwards compatibility (no API key) -- 4 tests
  • Unauthenticated filtering for projects -- 4 tests (list, detail, wrong token, public detail)
  • Authenticated access for projects -- 2 tests
  • Unauthenticated filtering for boards -- 4 tests (list, detail, wrong token, public detail)
  • Authenticated access for boards -- 2 tests
  • Edge cases -- 4 tests (all private, nonexistent slugs, is_public field in response)
  • Sub-resource endpoints (items, backlog, activity) -- 6 tests (auth + unauth for each)
  • Cross-endpoint interaction -- 1 test (project notes endpoint behavior documented)

Coverage is thorough. Happy paths, sad paths, edge cases, and cross-endpoint interactions are all represented.

test_project_notes_endpoint_respects_project_visibility: This test correctly documents that GET /projects/{slug}/notes does NOT check project-level is_public -- it finds the project by slug and only filters notes by Note.is_public. The test comment explains this is acceptable because only public notes are returned. This is a fair design decision: the endpoint leaks that a project slug exists but does not expose private content. Not a blocker, but worth noting for future hardening.

BLOCKERS

None. All blocker criteria pass:

  • Test coverage: 22 new tests covering all modified endpoints with auth/unauth/edge cases.
  • Input validation: No new user input fields introduced. Auth filtering uses the established get_is_authenticated dependency.
  • Secrets: No secrets or credentials in code. Test API key is a dummy value.
  • DRY in auth paths: Auth logic is centralized in auth.py:get_is_authenticated. Each endpoint only applies the boolean result -- no duplicated auth logic.

NITS

  1. Write endpoints lack auth gating (out of scope, but worth a follow-up issue): create_board, update_board, delete_board, sync_board, sync_issues, bulk_move_items, create_board_item, update_board_item, delete_board_item, create_project, update_project, delete_project -- none of these check is_authenticated. An unauthenticated request can create/modify/delete projects and boards. This is pre-existing and outside the scope of #184, but should be tracked as a separate issue.

  2. list_project_notes project-level visibility (documented in test): As noted above, GET /projects/{slug}/notes does not gate on project.is_public. The test documents this intentionally. A future hardening pass could return 404 for private project slugs here too, but the current behavior is safe since only public notes are returned.

  3. Test helper _create_projects_and_boards creates data outside patch context: In tests like test_no_api_key_all_projects_visible, the helper is called without patching pal_e_docs.auth.settings, which means get_is_authenticated returns True (no API key configured). This is correct for the "no API key" tests. For the "with API key" tests, the patch is active during both creation and querying, which is also correct. No issue here -- just noting the pattern is intentional and sound.

SOP COMPLIANCE

  • Branch named after issue: 184-projects-boards-is-public-filtering (references #184)
  • PR body follows template: Summary, Changes, Test Plan, Review Checklist, Related sections present
  • Related references plan slug: phase-pal-e-docs-f14-public-readiness
  • Tests exist: 22 new tests in tests/test_private_projects_boards.py
  • No secrets committed: dummy test API key only
  • No scope creep: all changes are directly related to #184
  • Commit messages: PR title is descriptive (fix: add is_public filtering to /projects and /boards endpoints)

PROCESS OBSERVATIONS

  • Change failure risk: Low. The change is additive (adding filtering to existing queries) and backwards-compatible (no API key = everything visible). The 22 tests plus the full 599-test regression pass provide high confidence.
  • Deployment frequency: No deployment blockers. This is a pure application-level change with no migration, infrastructure, or CI changes needed.
  • Security posture: This PR closes a real data exposure gap. Previously, private projects and their boards were fully visible to unauthenticated requests. The nit about write endpoints is a separate concern but should be addressed promptly.

VERDICT: APPROVED

## PR #185 Review ### DOMAIN REVIEW **Tech stack**: Python / FastAPI / SQLAlchemy (with SQLite test DB). This is a security-relevant change -- adding `is_public` filtering to read endpoints for projects and boards, extending the pattern established in PR #179 for notes. **Pattern consistency**: The implementation correctly mirrors the approach from `routes/notes.py`. The `get_is_authenticated` dependency is injected into all affected read endpoints, and the filtering logic follows the same `if not is_authenticated: q = q.filter(Project.is_public == True)` pattern. The `# noqa: E712` comments are correct -- SQLAlchemy requires `== True` rather than `is True` for column comparisons. **Endpoints covered by this PR**: - `GET /projects` -- filters by `Project.is_public` (list) - `GET /projects/{slug}` -- returns 404 for private when unauthenticated (detail) - `GET /boards` -- joins to Project, filters by `Project.is_public` (list) - `GET /boards/{slug}` -- returns 404 for private project when unauthenticated (detail) - `GET /boards/{slug}/items` -- returns 404 for private project board when unauthenticated - `GET /boards/backlog/items` -- joins Board->Project, filters by `Project.is_public` - `GET /boards/activity` -- joins Board->Project, filters by `Project.is_public` **SQLAlchemy join correctness**: The joins in `list_backlog_items` and `list_activity` correctly chain `BoardItem -> Board -> Project` using explicit join conditions (`BoardItem.board_id == Board.id`, `Board.project_id == Project.id`). The `list_boards` endpoint joins `Board -> Project` since Board has a direct FK to Project. All correct. **`get_board` / `list_board_items` approach**: These use `board.project.is_public` via lazy-loaded relationship rather than a join. This is fine for single-row lookups -- the lazy load is a single query and avoids unnecessary join complexity for a detail endpoint. **Auth module (`auth.py`)**: Uses `secrets.compare_digest` for timing-safe comparison. Backwards compatibility is preserved: when no API key is configured, `get_is_authenticated` returns `True` unconditionally. Solid. **Test coverage (22 tests)**: - Backwards compatibility (no API key) -- 4 tests - Unauthenticated filtering for projects -- 4 tests (list, detail, wrong token, public detail) - Authenticated access for projects -- 2 tests - Unauthenticated filtering for boards -- 4 tests (list, detail, wrong token, public detail) - Authenticated access for boards -- 2 tests - Edge cases -- 4 tests (all private, nonexistent slugs, `is_public` field in response) - Sub-resource endpoints (items, backlog, activity) -- 6 tests (auth + unauth for each) - Cross-endpoint interaction -- 1 test (project notes endpoint behavior documented) Coverage is thorough. Happy paths, sad paths, edge cases, and cross-endpoint interactions are all represented. **`test_project_notes_endpoint_respects_project_visibility`**: This test correctly documents that `GET /projects/{slug}/notes` does NOT check project-level `is_public` -- it finds the project by slug and only filters notes by `Note.is_public`. The test comment explains this is acceptable because only public notes are returned. This is a fair design decision: the endpoint leaks that a project slug exists but does not expose private content. Not a blocker, but worth noting for future hardening. ### BLOCKERS None. All blocker criteria pass: - **Test coverage**: 22 new tests covering all modified endpoints with auth/unauth/edge cases. - **Input validation**: No new user input fields introduced. Auth filtering uses the established `get_is_authenticated` dependency. - **Secrets**: No secrets or credentials in code. Test API key is a dummy value. - **DRY in auth paths**: Auth logic is centralized in `auth.py:get_is_authenticated`. Each endpoint only applies the boolean result -- no duplicated auth logic. ### NITS 1. **Write endpoints lack auth gating** (out of scope, but worth a follow-up issue): `create_board`, `update_board`, `delete_board`, `sync_board`, `sync_issues`, `bulk_move_items`, `create_board_item`, `update_board_item`, `delete_board_item`, `create_project`, `update_project`, `delete_project` -- none of these check `is_authenticated`. An unauthenticated request can create/modify/delete projects and boards. This is pre-existing and outside the scope of #184, but should be tracked as a separate issue. 2. **`list_project_notes` project-level visibility** (documented in test): As noted above, `GET /projects/{slug}/notes` does not gate on `project.is_public`. The test documents this intentionally. A future hardening pass could return 404 for private project slugs here too, but the current behavior is safe since only public notes are returned. 3. **Test helper `_create_projects_and_boards` creates data outside `patch` context**: In tests like `test_no_api_key_all_projects_visible`, the helper is called without patching `pal_e_docs.auth.settings`, which means `get_is_authenticated` returns `True` (no API key configured). This is correct for the "no API key" tests. For the "with API key" tests, the patch is active during both creation and querying, which is also correct. No issue here -- just noting the pattern is intentional and sound. ### SOP COMPLIANCE - [x] Branch named after issue: `184-projects-boards-is-public-filtering` (references #184) - [x] PR body follows template: Summary, Changes, Test Plan, Review Checklist, Related sections present - [x] Related references plan slug: `phase-pal-e-docs-f14-public-readiness` - [x] Tests exist: 22 new tests in `tests/test_private_projects_boards.py` - [x] No secrets committed: dummy test API key only - [x] No scope creep: all changes are directly related to #184 - [x] Commit messages: PR title is descriptive (`fix: add is_public filtering to /projects and /boards endpoints`) ### PROCESS OBSERVATIONS - **Change failure risk**: Low. The change is additive (adding filtering to existing queries) and backwards-compatible (no API key = everything visible). The 22 tests plus the full 599-test regression pass provide high confidence. - **Deployment frequency**: No deployment blockers. This is a pure application-level change with no migration, infrastructure, or CI changes needed. - **Security posture**: This PR closes a real data exposure gap. Previously, private projects and their boards were fully visible to unauthenticated requests. The nit about write endpoints is a separate concern but should be addressed promptly. ### VERDICT: APPROVED
forgejo_admin deleted branch 184-projects-boards-is-public-filtering 2026-03-17 00:12:17 +00:00
Sign in to join this conversation.
No description provided.