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: s7t8u9v0w1x2
Create Date: 2026-04-03
"""

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = "t8u9v0w1x2y3"
down_revision = "s7t8u9v0w1x2"
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
176 changes: 176 additions & 0 deletions ontokit/api/routes/anonymous_suggestions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""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.
Honeypot field ('website') triggers silent fake success for bot detection.
"""
_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_anonymous_token(token)
if verified_session_id is None:
from fastapi import HTTPException

raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired anonymous token",
)
# Delegate to the existing beacon_save (session lookup is by session_id, no user check)
await service.beacon_save(project_id, data, data.session_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
Comment on lines +151 to +176
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's a beacon_save_anonymous method or similar in the service
ast-grep --pattern 'async def beacon_save$_($$$) {
  $$$
}'

Repository: CatholicOS/ontokit-api

Length of output: 48


🏁 Script executed:

#!/bin/bash
# Find the suggestion service file
fd -type f -name "*suggestion*service*"

Repository: CatholicOS/ontokit-api

Length of output: 236


🏁 Script executed:

#!/bin/bash
# Search for the beacon_save method definition
rg -A 30 "async def beacon_save" --type py

Repository: CatholicOS/ontokit-api

Length of output: 4580


🏁 Script executed:

#!/bin/bash
# Also search for any beacon_save_anonymous method
rg "beacon_save" --type py -B 2 -A 10

Repository: CatholicOS/ontokit-api

Length of output: 5167


🏁 Script executed:

#!/bin/bash
# Search for beacon_save_anonymous or similar methods in the service
rg "def beacon_save" ontokit/services/ -A 3

Repository: CatholicOS/ontokit-api

Length of output: 414


🏁 Script executed:

#!/bin/bash
# Check what verify_anonymous_token returns and understand the token flow
rg "verify_anonymous_token|beacon_token" -B 3 -A 3

Repository: CatholicOS/ontokit-api

Length of output: 14050


Critical bug: beacon_save expects a beacon token, not a session ID — all anonymous beacon saves will fail.

The beacon_save method signature is async def beacon_save(self, project_id: UUID, data: SuggestionBeaconRequest, token: str), and its implementation calls verify_beacon_token(token) to validate the token. However, line 175 passes data.session_id (a plain identifier string) instead of a beacon token. This will cause verify_beacon_token(data.session_id) to fail immediately with "Invalid or expired beacon token" on every call.

Each session stores two separate tokens: an anonymous_token for initial authentication (verified at line 166 to get verified_session_id) and a beacon_token stored in the SuggestionSession model for beacon operations. The current code extracts verified_session_id but never uses it.

Pass the session's stored beacon_token to service.beacon_save instead, or create a dedicated beacon_save_anonymous method in the service that bypasses beacon token re-verification.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ontokit/api/routes/anonymous_suggestions.py` around lines 151 - 176, The
anonymous beacon handler calls service.beacon_save(project_id, data,
data.session_id) but beacon_save expects a beacon token and will call
verify_beacon_token(token); instead, after verify_anonymous_token(token) returns
verified_session_id you must load the SuggestionSession for that session ID and
extract its stored beacon_token, then call await service.beacon_save(project_id,
data, beacon_token); update anonymous_beacon_save (and/or add a service method
like beacon_save_anonymous on SuggestionService) to use the session's
beacon_token rather than data.session_id, referencing verify_anonymous_token,
anonymous_beacon_save, SuggestionService.beacon_save, and
SuggestionBeaconRequest to locate the code.

85 changes: 85 additions & 0 deletions ontokit/core/anonymous_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""HMAC-based anonymous session token for unauthenticated suggestion workflows.

Anonymous tokens are long-lived, session-scoped tokens that allow
unauthenticated users to save and submit suggestion sessions without
needing a Bearer token. They are used when AUTH_MODE is "optional" or "disabled".
"""

import base64
import hashlib
import hmac
import json
import time

from ontokit.core.config import settings

_INSECURE_DEFAULTS = {"change-me-in-production", ""}
_MIN_SECRET_LENGTH = 16

# Prefix added to HMAC input to prevent token type confusion with beacon tokens
_HMAC_PREFIX = "anon:"


def _check_secret_key() -> None:
"""Raise if secret_key is an insecure placeholder or too short."""
key = settings.secret_key
if key in _INSECURE_DEFAULTS or len(key) < _MIN_SECRET_LENGTH:
raise RuntimeError(
"SECRET_KEY is not configured securely. "
"Set a strong, random SECRET_KEY (>= 16 characters) before using anonymous tokens."
)


def create_anonymous_token(session_id: str, ttl: int = 86400) -> str:
"""Create an HMAC-signed anonymous session token.

Args:
session_id: The suggestion session ID to scope the token to.
ttl: Time-to-live in seconds (default 24 hours).

Returns:
Base64url-encoded token string.
"""
_check_secret_key()
if ttl <= 0:
raise ValueError("ttl must be a positive number of seconds")
payload = json.dumps({"sid": session_id, "exp": int(time.time()) + ttl})
# Prepend "anon:" to differentiate from beacon tokens using the same secret
sig = hmac.new(
settings.secret_key.encode(), (_HMAC_PREFIX + payload).encode(), hashlib.sha256
).hexdigest()
return base64.urlsafe_b64encode(f"{payload}|{sig}".encode()).decode()


def verify_anonymous_token(token: str) -> str | None:
"""Verify an anonymous session token and return the session_id if valid.

Args:
token: The base64url-encoded token string.

Returns:
The session_id if the token is valid and not expired, None otherwise.
"""
_check_secret_key()
try:
decoded = base64.urlsafe_b64decode(token.encode()).decode()
payload_str, sig = decoded.rsplit("|", 1)
expected = hmac.new(
settings.secret_key.encode(),
(_HMAC_PREFIX + payload_str).encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
payload = json.loads(payload_str)
if not isinstance(payload, dict):
return None
exp = payload.get("exp")
sid = payload.get("sid")
if not isinstance(exp, (int, float)) or not isinstance(sid, str):
return None
if time.time() > exp:
return None
return sid
except Exception:
return None
20 changes: 20 additions & 0 deletions ontokit/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ def is_superadmin(self) -> bool:
return self.id in settings.superadmin_ids


# Anonymous user returned when auth is disabled
ANONYMOUS_USER = CurrentUser(
id="anonymous",
email=None,
name="Anonymous",
username="anonymous",
roles=["viewer"],
)

# Cache for JWKS (JSON Web Key Set) with TTL
_jwks_cache: dict[str, Any] | None = None
_jwks_cache_time: float = 0.0
Expand Down Expand Up @@ -252,6 +261,10 @@ async def get_current_user(

Raises 401 if not authenticated.
"""
if settings.auth_mode == "disabled":
return ANONYMOUS_USER
# "optional" mode: still require auth for RequiredUser (401 if no credentials)
# "required" mode: existing behavior (401 if no credentials)
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand Down Expand Up @@ -293,6 +306,10 @@ async def get_current_user_optional(

Useful for endpoints that work differently for authenticated vs anonymous users.
"""
if settings.auth_mode == "disabled":
return ANONYMOUS_USER
# "optional" mode: existing behavior — returns None if no credentials, real user if valid token
# "required" mode: existing behavior
if credentials is None:
return None

Expand All @@ -311,6 +328,9 @@ async def get_current_user_with_token(
Raises 401 if not authenticated.
Returns tuple of (CurrentUser, access_token).
"""
if settings.auth_mode == "disabled":
return ANONYMOUS_USER, "anonymous"
# "optional" and "required" modes: existing behavior (401 if no credentials)
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand Down
3 changes: 3 additions & 0 deletions ontokit/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ def zitadel_jwks_base_url(self) -> str:
frontend_url: str = "" # e.g. http://localhost:3000
revalidation_secret: str = "" # shared secret for sitemap revalidation

# Auth mode: "required" (default), "optional" (browse without login, sign in for editing), "disabled" (no auth)
auth_mode: str = "required"

# Superadmin - comma-separated list of user IDs with full system access
superadmin_user_ids: str = ""

Expand Down
8 changes: 7 additions & 1 deletion ontokit/models/suggestion_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from enum import StrEnum
from typing import TYPE_CHECKING

from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

if TYPE_CHECKING:
Expand Down Expand Up @@ -54,6 +54,12 @@ class SuggestionSession(Base):
# Auth
beacon_token: Mapped[str] = mapped_column(String(500), nullable=False)

# Anonymous session fields
is_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
submitter_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
submitter_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
client_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
Comment on lines +57 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Migration schema mismatch with model definitions.

The Alembic migration (per context snippet alembic/versions/t8u9v0w1x2y3_add_anonymous_suggestion_fields.py:18-22) uses sa.String() without length constraints for submitter_name, submitter_email, and client_ip, but the model specifies String(255), String(255), and String(45) respectively. On databases like MySQL that require explicit VARCHAR lengths, this mismatch will cause schema inconsistencies or failures.

Update the migration to include the length constraints:

op.add_column("suggestion_sessions", sa.Column("submitter_name", sa.String(255), nullable=True))
op.add_column("suggestion_sessions", sa.Column("submitter_email", sa.String(255), nullable=True))
op.add_column("suggestion_sessions", sa.Column("client_ip", sa.String(45), nullable=True))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ontokit/models/suggestion_session.py` around lines 57 - 61, The Alembic
revision t8u9v0w1x2y3_add_anonymous_suggestion_fields.py adds columns without
length constraints that conflict with the SuggestionSession model fields
(submitter_name, submitter_email, client_ip) which use String(255)/String(45);
update the migration's op.add_column calls to use sa.String(255) for
submitter_name and submitter_email and sa.String(45) for client_ip so the DB
schema matches the model definitions.


# PR link (set after submit)
pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
pr_id: Mapped[uuid.UUID | None] = mapped_column(
Expand Down
Loading
Loading