Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions alembic/versions/t8u9v0w1x2y3_add_anonymous_suggestion_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Add anonymous suggestion fields to suggestion_sessions.

Revision ID: t8u9v0w1x2y3
Revises: 47cc27515626
Create Date: 2026-04-03
"""

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = "t8u9v0w1x2y3"
down_revision = "47cc27515626"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column("suggestion_sessions", sa.Column("is_anonymous", sa.Boolean(), server_default="false", nullable=False))
op.add_column("suggestion_sessions", sa.Column("submitter_name", sa.String(), nullable=True))
op.add_column("suggestion_sessions", sa.Column("submitter_email", sa.String(), nullable=True))
op.add_column("suggestion_sessions", sa.Column("client_ip", sa.String(), nullable=True))


def downgrade() -> None:
op.drop_column("suggestion_sessions", "client_ip")
op.drop_column("suggestion_sessions", "submitter_email")
op.drop_column("suggestion_sessions", "submitter_name")
op.drop_column("suggestion_sessions", "is_anonymous")
4 changes: 4 additions & 0 deletions ontokit/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from ontokit.api.routes import (
analytics,
anonymous_suggestions,
auth,
classes,
embeddings,
Expand Down Expand Up @@ -39,6 +40,9 @@
router.include_router(classes.router, tags=["Classes"])
router.include_router(properties.router, tags=["Properties"])
router.include_router(suggestions.router, prefix="/projects", tags=["Suggestions"])
router.include_router(
anonymous_suggestions.router, prefix="/projects", tags=["anonymous-suggestions"]
)
router.include_router(remote_sync.router, prefix="/projects", tags=["Sync from Remote"])
router.include_router(notifications.router, prefix="/notifications", tags=["Notifications"])
router.include_router(search.router, prefix="/search", tags=["Search"])
Expand Down
170 changes: 170 additions & 0 deletions ontokit/api/routes/anonymous_suggestions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Anonymous suggestion session endpoints.

Provides create/save/submit/discard/beacon endpoints for unauthenticated users.
All endpoints are gated on AUTH_MODE != "required".
"""

from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, Header, Query, Request, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession

from ontokit.core.anonymous_token import verify_anonymous_token
from ontokit.core.config import settings
from ontokit.core.database import get_db
from ontokit.schemas.anonymous_suggestion import (
AnonymousSessionCreateResponse,
AnonymousSubmitRequest,
AnonymousSubmitResponse,
)
from ontokit.schemas.suggestion import (
SuggestionBeaconRequest,
SuggestionSaveRequest,
SuggestionSaveResponse,
)
from ontokit.services.suggestion_service import SuggestionService, get_suggestion_service

router = APIRouter()


def _require_anonymous_mode() -> None:
"""Raise 403 if anonymous suggestions are not enabled."""
from fastapi import HTTPException

if settings.auth_mode == "required":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Anonymous suggestions not available",
)


def _verify_anon_token(x_anonymous_token: str) -> str:
"""Verify the X-Anonymous-Token header and return the session_id.

Raises 401 if the token is missing, invalid, or expired.
"""
from fastapi import HTTPException

verified = verify_anonymous_token(x_anonymous_token)
if verified is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired anonymous token",
)
return verified


def get_service(db: Annotated[AsyncSession, Depends(get_db)]) -> SuggestionService:
"""Dependency to get suggestion service with database session."""
return get_suggestion_service(db)


@router.post(
"/{project_id}/suggestions/anonymous/sessions",
response_model=AnonymousSessionCreateResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_anonymous_session(
project_id: UUID,
request: Request,
service: Annotated[SuggestionService, Depends(get_service)],
) -> AnonymousSessionCreateResponse:
"""Create a new anonymous suggestion session.

No authentication required. Rate-limited to 5 sessions per IP per hour.
Only available when AUTH_MODE is not "required".
"""
_require_anonymous_mode()
client_ip = request.client.host if request.client else "unknown"
return await service.create_anonymous_session(project_id, client_ip)


@router.put(
"/{project_id}/suggestions/anonymous/sessions/{session_id}/save",
response_model=SuggestionSaveResponse,
)
async def save_anonymous_session(
project_id: UUID,
session_id: str,
data: SuggestionSaveRequest,
service: Annotated[SuggestionService, Depends(get_service)],
x_anonymous_token: Annotated[str, Header()],
) -> SuggestionSaveResponse:
"""Save content to an anonymous suggestion session.

Authenticated via X-Anonymous-Token header.
"""
_require_anonymous_mode()
verified_session_id = _verify_anon_token(x_anonymous_token)
return await service.save_anonymous(project_id, session_id, data, verified_session_id)


@router.post(
"/{project_id}/suggestions/anonymous/sessions/{session_id}/submit",
response_model=AnonymousSubmitResponse,
)
async def submit_anonymous_session(
project_id: UUID,
session_id: str,
data: AnonymousSubmitRequest,
service: Annotated[SuggestionService, Depends(get_service)],
x_anonymous_token: Annotated[str, Header()],
) -> AnonymousSubmitResponse:
"""Submit an anonymous suggestion session as a pull request.

Authenticated via X-Anonymous-Token header.
"""
_require_anonymous_mode()
verified_session_id = _verify_anon_token(x_anonymous_token)

# Honeypot check: bots fill the 'website' field, humans leave it blank
if data.honeypot is not None and data.honeypot != "":
# Silent fake success — do not create anything
return AnonymousSubmitResponse(pr_number=0, pr_url=None, status="submitted")

return await service.submit_anonymous(project_id, session_id, data, verified_session_id)


@router.post(
"/{project_id}/suggestions/anonymous/sessions/{session_id}/discard",
status_code=status.HTTP_204_NO_CONTENT,
)
async def discard_anonymous_session(
project_id: UUID,
session_id: str,
service: Annotated[SuggestionService, Depends(get_service)],
x_anonymous_token: Annotated[str, Header()],
) -> Response:
"""Discard an anonymous suggestion session and delete its branch.

Authenticated via X-Anonymous-Token header.
"""
_require_anonymous_mode()
verified_session_id = _verify_anon_token(x_anonymous_token)
await service.discard_anonymous(project_id, session_id, verified_session_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)


@router.post(
"/{project_id}/suggestions/anonymous/beacon",
status_code=status.HTTP_204_NO_CONTENT,
)
async def anonymous_beacon_save(
project_id: UUID,
data: SuggestionBeaconRequest,
service: Annotated[SuggestionService, Depends(get_service)],
token: str = Query(..., description="Anonymous session token for authentication"),
) -> Response:
"""Handle a sendBeacon flush for anonymous sessions.

Authenticated via 'token' query parameter (same pattern as authenticated beacon).
"""
_require_anonymous_mode()
verified_session_id = _verify_anon_token(token)
# beacon_save_anonymous binds the verified token to the payload session and
# re-checks is_anonymous (the lineage version passed data.session_id where a
# BEACON token was expected — the endpoint always 401'd; fixed in PR-7).
await service.beacon_save_anonymous(project_id, data, verified_session_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
126 changes: 85 additions & 41 deletions ontokit/api/utils/ws_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,68 +5,112 @@

from fastapi import HTTPException, WebSocket

from ontokit.core.auth import CurrentUser, fetch_userinfo, validate_token
from ontokit.core.auth import (
ANONYMOUS_USER,
CurrentUser,
fetch_userinfo,
validate_token,
)
from ontokit.core.config import settings
from ontokit.core.database import async_session_maker
from ontokit.services.project_service import ProjectService

logger = logging.getLogger(__name__)


async def _build_user_from_token(token: str) -> CurrentUser:
"""Validate a JWT and assemble the ``CurrentUser``.

Mirrors the user-assembly half of ``core.auth.get_current_user`` (userinfo
backfill for missing name/email) so WebSocket callers get the same identity
an HTTP request with the same token would.
"""
payload = await validate_token(token)
name = payload.name
email = payload.email
username = payload.preferred_username
if not name or not email:
userinfo = await fetch_userinfo(token)
if userinfo:
name = name or userinfo.get("name") or userinfo.get("preferred_username")
email = email or userinfo.get("email")
username = username or userinfo.get("preferred_username")
return CurrentUser(
id=payload.sub, email=email, name=name, username=username, roles=payload.roles
)


async def authenticate_ws(
websocket: WebSocket,
project_id: UUID,
token: str | None,
) -> bool:
"""Authenticate a WebSocket connection and verify project access.

Validates the JWT token, builds a ``CurrentUser``, and checks project
access via ``ProjectService.get``. Returns ``True`` if the caller
should proceed (auth + access succeeded). Returns ``False`` after
closing the WebSocket with an appropriate code when auth or access
fails.
Resolves the caller identity **honoring ``settings.auth_mode``**, then checks
project access via ``ProjectService.get``. Returns ``True`` if the caller
should proceed, or ``False`` after closing the WebSocket with an appropriate
code when auth or access fails.

HTTP 401/403/404 from the auth/service layer are translated to
WebSocket close codes:
``auth_mode`` parity with the HTTP dependencies in ``ontokit.core.auth``
(``get_current_user`` / ``get_current_user_optional``) — without this a
``disabled`` or ``optional`` deployment would admit anonymous callers on its
HTTP API but still reject them at the WebSocket handshake:

* **4001** – missing or invalid token
* **4003** – authenticated but access denied
* ``disabled`` — everyone is the shared :data:`ANONYMOUS_USER`; **no token
required** (matches ``get_current_user``'s disabled branch).
* ``optional`` — an absent *or* invalid token downgrades to
:data:`ANONYMOUS_USER` (matches ``get_current_user_optional``); the
project-access check below still gates private projects.
* ``required`` — a valid token is mandatory (original behavior).

HTTP 401/403/404 from the auth/service layer are translated to WebSocket
close codes:

* **4001** – missing or invalid token (``required`` mode only)
* **4003** – authenticated (or anonymous) but access denied
* **4004** – project not found

Unexpected server errors are closed with **1011** (internal error)
and logged.
Unexpected server errors are closed with **1011** (internal error) and logged.

The WebSocket is accepted before any error close so that the client
receives a proper close frame rather than a raw HTTP 403.
The WebSocket is accepted before any error close so that the client receives
a proper close frame rather than a raw HTTP 403.
"""
await websocket.accept()

# --- Token required ---
if not token:
await websocket.close(code=4001, reason="Authentication required")
return False

# --- Validate JWT ---
try:
payload = await validate_token(token)
name = payload.name
email = payload.email
username = payload.preferred_username
if not name or not email:
userinfo = await fetch_userinfo(token)
if userinfo:
name = name or userinfo.get("name") or userinfo.get("preferred_username")
email = email or userinfo.get("email")
username = username or userinfo.get("preferred_username")
user = CurrentUser(
id=payload.sub, email=email, name=name, username=username, roles=payload.roles
)
except HTTPException:
await websocket.close(code=4001, reason="Invalid or expired token")
return False
except Exception:
logger.exception("Unexpected error during WebSocket token validation")
await websocket.close(code=1011, reason="Internal server error")
return False
# --- Resolve caller identity per auth_mode (parity with core.auth) ---
user: CurrentUser
if settings.auth_mode == "disabled":
# Auth fully disabled — the shared anonymous identity, no token needed.
user = ANONYMOUS_USER
elif settings.auth_mode == "optional":
# Mirror get_current_user_optional: absent/invalid token → anonymous.
# The project-access check still enforces private-project boundaries.
if not token:
user = ANONYMOUS_USER
else:
try:
user = await _build_user_from_token(token)
except HTTPException:
user = ANONYMOUS_USER
except Exception:
logger.exception("Unexpected error during WebSocket token validation")
await websocket.close(code=1011, reason="Internal server error")
return False
else:
# "required" mode: a valid token is mandatory.
if not token:
await websocket.close(code=4001, reason="Authentication required")
return False
try:
user = await _build_user_from_token(token)
except HTTPException:
await websocket.close(code=4001, reason="Invalid or expired token")
return False
except Exception:
logger.exception("Unexpected error during WebSocket token validation")
await websocket.close(code=1011, reason="Internal server error")
return False

# --- Verify project access ---
try:
Expand Down
Loading
Loading