Skip to content

Commit af6d73a

Browse files
committed
feat: add ANONYMOUS_READ.
1 parent 4e82e18 commit af6d73a

34 files changed

Lines changed: 1077 additions & 169 deletions

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ ALLOWED_ORIGINS=
2222
# client to the proxy's IP.
2323
TRUST_PROXY=false
2424

25+
# ── Anonymous Read (Demo / Public Wiki Mode) ──
26+
# When true, visitors without a valid session are treated as a synthetic
27+
# "guest" viewer (role=viewer) instead of being redirected to /login.
28+
# They can browse the page tree, search, graph, recent activity, and any
29+
# page whose ACL chain has no anchor (the open-default set). Writes,
30+
# personal endpoints (bookmarks, watch, comments POST, profile, tokens),
31+
# AI chat, and admin endpoints all stay login-required.
32+
# Default off so existing private deployments are unaffected.
33+
ANONYMOUS_READ=false
34+
2535
# ── AI Chat (optional) ──
2636
# Defaults target Gemini. Get an API key at https://aistudio.google.com/apikey
2737
# Any OpenAI-compatible provider works — just change AI_BASE_URL + AI_MODEL.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ Resolution logic (in `services/acl.py` — routers must use these helpers, never
112112
- Per-page ACL rows live in `page_acl (page_id, principal_type, principal_id, permission)` where principal is a user or group.
113113
- To resolve: walk the `parent_id` chain, find the shallowest ancestor with any ACL row (the "anchor"), and take the most-permissive matching row. If no anchor exists, the page is open by default (write for editors, read for viewers).
114114
- `viewer` role is capped at `read` even when ACL grants write.
115+
- When `ANONYMOUS_READ=true` (env), unauthenticated requests get a synthetic guest user (`id=0`, `role=viewer`, `anonymous=True`) instead of 401. ACL still gates everything — guests can only read pages with no ACL anchor (the open-default set). All write/personal endpoints (bookmarks, comments POST, watch, profile, tokens, AI) reject the guest via `auth.require_real_user`; admin endpoints reject via `require_admin`. `/api/auth/me` keeps returning 401 for unauthenticated requests so the frontend can distinguish guest from logged-in.
115116
- Frontend: `usePermissions` store caches per-slug; seeded from `effective_permission` on page-view responses. Helper functions `canEdit`, `canRead`, `canManageAcl` are exported from the store file.
116117

117118
## Themes

backend/app/auth.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@
1717
API_TOKEN_PREFIX = "jwk_"
1818
API_TOKEN_DISPLAY_PREFIX_LEN = 12 # "jwk_" + 8 chars shown in the UI
1919

20+
# Synthetic id for the guest user produced when ANONYMOUS_READ is on and
21+
# the request carries no valid credentials. The users table starts at 1
22+
# (AUTOINCREMENT), so 0 cannot collide with any real account.
23+
ANONYMOUS_USER_ID = 0
24+
25+
26+
def anonymous_user() -> dict:
27+
"""Synthetic viewer used when ANONYMOUS_READ=true and creds are absent.
28+
29+
Carries the `anonymous=True` flag so write endpoints can reject it via
30+
`require_real_user` and the ACL layer can short-circuit cheaply.
31+
"""
32+
return {
33+
"id": ANONYMOUS_USER_ID,
34+
"username": "guest",
35+
"role": "viewer",
36+
"display_name": "Guest",
37+
"email": "",
38+
"anonymous": True,
39+
}
40+
2041

2142
def hash_api_token(token: str) -> str:
2243
"""Return the canonical hash we store in api_tokens.token_hash.
@@ -98,7 +119,7 @@ async def _resolve_api_token(token: str) -> dict | None:
98119
}
99120

100121

101-
async def _resolve_request_credentials(request: Request) -> dict | None:
122+
async def resolve_request_credentials(request: Request) -> dict | None:
102123
"""Decode whichever credential the request carries, or return None.
103124
104125
Recognises both personal API tokens (prefixed with `jwk_`) and JWT
@@ -136,21 +157,42 @@ async def _resolve_request_credentials(request: Request) -> dict | None:
136157

137158

138159
async def get_current_user(request: Request):
139-
user = await _resolve_request_credentials(request)
140-
if user is None:
141-
raise HTTPException(
142-
status_code=status.HTTP_401_UNAUTHORIZED,
143-
detail="Not authenticated",
144-
)
145-
return user
160+
user = await resolve_request_credentials(request)
161+
if user is not None:
162+
return user
163+
# When ANONYMOUS_READ is on, fall through to a synthetic guest viewer
164+
# rather than 401. ACL caps viewers at `read` and write/admin endpoints
165+
# use `require_real_user` / `require_admin` to reject the guest, so this
166+
# opens reads without affecting writes.
167+
if settings.ANONYMOUS_READ:
168+
return anonymous_user()
169+
raise HTTPException(
170+
status_code=status.HTTP_401_UNAUTHORIZED,
171+
detail="Not authenticated",
172+
)
146173

147174

148175
async def get_optional_user(request: Request) -> dict | None:
149176
"""Same credential resolution as `get_current_user` but returns None
150177
instead of raising on missing/invalid credentials. Use for endpoints
151178
that serve both authenticated and anonymous traffic (e.g. media
152179
files referenced by public pages)."""
153-
return await _resolve_request_credentials(request)
180+
return await resolve_request_credentials(request)
181+
182+
183+
async def require_real_user(user=Depends(get_current_user)):
184+
"""Reject the synthetic anonymous user.
185+
186+
Use on endpoints that don't go through ACL (bookmarks, comments POST,
187+
tokens, notifications, watch, profile, password, ai). ACL-gated writes
188+
don't need this — viewer cap turns them into 403 automatically.
189+
"""
190+
if user.get("anonymous"):
191+
raise HTTPException(
192+
status_code=status.HTTP_401_UNAUTHORIZED,
193+
detail="Login required",
194+
)
195+
return user
154196

155197

156198
async def require_admin(user=Depends(get_current_user)):

backend/app/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ class Settings(BaseSettings):
3939
OIDC_ENABLED: bool = False
4040
OIDC_PROVIDERS: str = "" # comma-separated: google,github,generic
4141

42+
# When True, requests without valid credentials are treated as a
43+
# synthetic "guest" viewer (id=0, role=viewer) instead of being rejected
44+
# with 401. ACL still gates access — guests only see pages with no ACL
45+
# anchor in their parent chain (the open-default set). All write/admin
46+
# endpoints remain login-required (see auth.require_real_user).
47+
# Default off so existing private-wiki deployments are unaffected.
48+
ANONYMOUS_READ: bool = False
49+
4250
# Access-control layers. Any rule that is set and does not match → 403.
4351
OIDC_ALLOW_SIGNUP: bool = False # if False, only pre-provisioned users
4452
OIDC_ALLOWED_EMAILS: str = "" # comma-separated individual whitelist

backend/app/routers/activity.py

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from fastapi import APIRouter, Depends, Query
33
from app.auth import get_current_user
44
from app.database import get_db
5+
from app.services.acl import list_readable_page_ids
56

67
router = APIRouter(prefix="/api/activity", tags=["activity"])
78

@@ -16,6 +17,24 @@ async def log_activity(db, user_id: int, action: str, target_type: str, target_i
1617
)
1718

1819

20+
def _readable_clause(readable: frozenset[int] | set[int]) -> tuple[str, list]:
21+
"""SQL fragment + params for "target_type='page' AND target_id ∈ readable".
22+
23+
Non-page rows (e.g. comment activity) are kept as-is — currently every
24+
write that calls log_activity uses target_type='page', so the filter is
25+
effectively a whitelist; if non-page targets are added later, they'll
26+
pass through and may need their own ACL gate.
27+
"""
28+
if not readable:
29+
# No readable pages → drop every page-targeted row.
30+
return "(a.target_type != 'page')", []
31+
placeholders = ",".join("?" * len(readable))
32+
return (
33+
f"(a.target_type != 'page' OR a.target_id IN ({placeholders}))",
34+
list(readable),
35+
)
36+
37+
1938
@router.get("")
2039
async def list_activity(
2140
page: int = Query(1, ge=1),
@@ -25,17 +44,27 @@ async def list_activity(
2544
db = await get_db()
2645
offset = (page - 1) * per_page
2746

28-
count_rows = await db.execute_fetchall("SELECT COUNT(*) as cnt FROM activity_log")
47+
# Filter activity to entries about pages the caller can read; otherwise
48+
# the feed leaks titles/slugs of restricted pages via the metadata blob.
49+
# Admin is short-circuited inside list_readable_page_ids.
50+
readable = await list_readable_page_ids(db, user)
51+
where_sql, where_params = _readable_clause(readable)
52+
53+
count_rows = await db.execute_fetchall(
54+
f"SELECT COUNT(*) as cnt FROM activity_log a WHERE {where_sql}",
55+
where_params,
56+
)
2957
total = count_rows[0]["cnt"]
3058

3159
rows = await db.execute_fetchall(
32-
"""SELECT a.id, a.user_id, a.action, a.target_type, a.target_id, a.metadata, a.created_at,
33-
u.username, u.display_name
60+
f"""SELECT a.id, a.user_id, a.action, a.target_type, a.target_id, a.metadata, a.created_at,
61+
u.username, u.display_name
3462
FROM activity_log a
3563
LEFT JOIN users u ON u.id = a.user_id
64+
WHERE {where_sql}
3665
ORDER BY a.created_at DESC, a.id DESC
3766
LIMIT ? OFFSET ?""",
38-
(per_page, offset),
67+
where_params + [per_page, offset],
3968
)
4069

4170
results = []
@@ -52,36 +81,67 @@ async def list_activity(
5281
async def activity_stats(user=Depends(get_current_user)):
5382
db = await get_db()
5483

55-
# Top viewed pages
84+
# Filter every page-derived list by the caller's readable set so guests
85+
# (and any role that can't read everything) don't see restricted titles
86+
# in top_viewed / recently_updated / orphan_pages.
87+
readable = await list_readable_page_ids(db, user)
88+
89+
if not readable:
90+
# No readable pages → all page-keyed lists are empty. Still return
91+
# the shape so the dashboard renders without null-checks.
92+
return {
93+
"top_viewed": [],
94+
"recently_updated": [],
95+
"orphan_pages": [],
96+
"total_pages": 0,
97+
# Suppress global user count for callers without read access to
98+
# any page — they have no business enumerating the user roster.
99+
"total_users": 0,
100+
}
101+
102+
placeholders = ",".join("?" * len(readable))
103+
readable_params = list(readable)
104+
56105
top_viewed = await db.execute_fetchall(
57-
"""SELECT id, slug, title, view_count FROM pages
58-
ORDER BY view_count DESC LIMIT 10"""
106+
f"""SELECT id, slug, title, view_count FROM pages
107+
WHERE id IN ({placeholders})
108+
ORDER BY view_count DESC LIMIT 10""",
109+
readable_params,
59110
)
60111

61-
# Recently updated pages
62112
recently_updated = await db.execute_fetchall(
63-
"""SELECT p.id, p.slug, p.title, p.updated_at
113+
f"""SELECT p.id, p.slug, p.title, p.updated_at
64114
FROM pages p
65-
ORDER BY p.updated_at DESC LIMIT 10"""
115+
WHERE p.id IN ({placeholders})
116+
ORDER BY p.updated_at DESC LIMIT 10""",
117+
readable_params,
66118
)
67119

68-
# Orphan pages (no backlinks pointing to them, not linked from anywhere)
69120
orphan_pages = await db.execute_fetchall(
70-
"""SELECT p.id, p.slug, p.title, p.view_count
121+
f"""SELECT p.id, p.slug, p.title, p.view_count
71122
FROM pages p
72-
WHERE p.id NOT IN (SELECT target_page_id FROM backlinks)
123+
WHERE p.id IN ({placeholders})
124+
AND p.id NOT IN (SELECT target_page_id FROM backlinks)
73125
AND p.parent_id IS NULL
74-
ORDER BY p.updated_at DESC LIMIT 20"""
126+
ORDER BY p.updated_at DESC LIMIT 20""",
127+
readable_params,
75128
)
76129

77-
# Total counts
78-
total_pages_row = await db.execute_fetchall("SELECT COUNT(*) as cnt FROM pages")
79-
total_users_row = await db.execute_fetchall("SELECT COUNT(*) as cnt FROM users")
130+
# total_pages reflects what *this user* can read, not the global count.
131+
# Anonymous and viewers see the open-default subset; admins see all.
132+
total_pages = len(readable)
133+
# Hide the user roster size from the synthetic guest. Real users on a
134+
# small-team wiki are expected to know the roster, so editors+ see it.
135+
if user.get("anonymous"):
136+
total_users = 0
137+
else:
138+
total_users_row = await db.execute_fetchall("SELECT COUNT(*) as cnt FROM users")
139+
total_users = total_users_row[0]["cnt"]
80140

81141
return {
82142
"top_viewed": [dict(r) for r in top_viewed],
83143
"recently_updated": [dict(r) for r in recently_updated],
84144
"orphan_pages": [dict(r) for r in orphan_pages],
85-
"total_pages": total_pages_row[0]["cnt"],
86-
"total_users": total_users_row[0]["cnt"],
145+
"total_pages": total_pages,
146+
"total_users": total_users,
87147
}

backend/app/routers/ai.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,18 @@
1818
from fastapi.responses import StreamingResponse
1919
from pydantic import BaseModel, Field
2020

21-
from app.auth import get_current_user
21+
from app.auth import get_current_user, require_real_user
2222
from app.config import settings
2323
from app.database import get_db
2424
from app.services.acl import list_readable_page_ids
2525
from app.services.search import segment
2626

27-
router = APIRouter(prefix="/api/ai", tags=["ai"])
27+
# AI calls hit a paid upstream — never expose to anonymous traffic.
28+
router = APIRouter(
29+
prefix="/api/ai",
30+
tags=["ai"],
31+
dependencies=[Depends(require_real_user)],
32+
)
2833

2934
# FTS5 trigram tokenizer needs ≥3 chars; below that we fall back to LIKE —
3035
# same threshold as routers/search.py so retrieval behaves identically to

backend/app/routers/auth_router.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
from app.schemas import LoginRequest, UserResponse
66
from typing import Optional
77
from pydantic import BaseModel
8-
from app.auth import verify_password, create_token, get_current_user, hash_password
8+
from app.auth import (
9+
verify_password,
10+
create_token,
11+
hash_password,
12+
require_real_user,
13+
resolve_request_credentials,
14+
)
915
from app.config import settings
1016
from app.database import get_db
1117
from app.services.client_ip import client_ip
@@ -104,7 +110,14 @@ async def logout(response: Response):
104110

105111

106112
@router.get("/me", response_model=UserResponse)
107-
async def me(user=Depends(get_current_user)):
113+
async def me(request: Request):
114+
# Always 401 when there's no real session, even with ANONYMOUS_READ on.
115+
# The frontend uses this 401 to detect "I'm a guest" vs "I'm logged in";
116+
# if /me silently returned the synthetic guest, the UI couldn't tell
117+
# the difference and would render the logged-in chrome.
118+
user = await resolve_request_credentials(request)
119+
if user is None:
120+
raise HTTPException(status_code=401, detail="Not authenticated")
108121
return user
109122

110123

@@ -114,7 +127,7 @@ class ProfileUpdateRequest(BaseModel):
114127

115128

116129
@router.get("/profile")
117-
async def get_profile(user=Depends(get_current_user)):
130+
async def get_profile(user=Depends(require_real_user)):
118131
db = await get_db()
119132
rows = await db.execute_fetchall(
120133
"SELECT id, username, role, display_name, email, created_at FROM users WHERE id = ?",
@@ -124,7 +137,7 @@ async def get_profile(user=Depends(get_current_user)):
124137

125138

126139
@router.put("/profile")
127-
async def update_profile(body: ProfileUpdateRequest, user=Depends(get_current_user)):
140+
async def update_profile(body: ProfileUpdateRequest, user=Depends(require_real_user)):
128141
db = await get_db()
129142
updates = []
130143
values = []
@@ -157,7 +170,7 @@ class ChangePasswordRequest(BaseModel):
157170

158171

159172
@router.put("/password")
160-
async def change_password(body: ChangePasswordRequest, user=Depends(get_current_user)):
173+
async def change_password(body: ChangePasswordRequest, user=Depends(require_real_user)):
161174
if len(body.new_password) < 8:
162175
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
163176

backend/app/routers/bookmarks.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
from fastapi import APIRouter, HTTPException, Depends
2-
from app.auth import get_current_user
2+
from app.auth import get_current_user, require_real_user
33
from app.database import get_db
44

5-
router = APIRouter(prefix="/api/bookmarks", tags=["bookmarks"])
5+
# Bookmarks are inherently per-user; the synthetic guest has no place here,
6+
# so reject it at the router boundary instead of repeating the check on
7+
# every endpoint.
8+
router = APIRouter(
9+
prefix="/api/bookmarks",
10+
tags=["bookmarks"],
11+
dependencies=[Depends(require_real_user)],
12+
)
613

714

815
@router.get("")

0 commit comments

Comments
 (0)