-
Notifications
You must be signed in to change notification settings - Fork 2
feat: AUTH_MODE, anonymous suggestions, seed script, and index fix #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
b6060df
d39cc77
168ef3d
946d3a6
97bf190
31d81ad
3618674
a3315fc
2392683
65dac4f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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") |
| 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) | ||
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Migration schema mismatch with model definitions. The Alembic migration (per context snippet 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 |
||
|
|
||
| # PR link (set after submit) | ||
| pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True) | ||
| pr_id: Mapped[uuid.UUID | None] = mapped_column( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: CatholicOS/ontokit-api
Length of output: 48
🏁 Script executed:
Repository: CatholicOS/ontokit-api
Length of output: 236
🏁 Script executed:
Repository: CatholicOS/ontokit-api
Length of output: 4580
🏁 Script executed:
Repository: CatholicOS/ontokit-api
Length of output: 5167
🏁 Script executed:
Repository: CatholicOS/ontokit-api
Length of output: 414
🏁 Script executed:
Repository: CatholicOS/ontokit-api
Length of output: 14050
Critical bug:
beacon_saveexpects a beacon token, not a session ID — all anonymous beacon saves will fail.The
beacon_savemethod signature isasync def beacon_save(self, project_id: UUID, data: SuggestionBeaconRequest, token: str), and its implementation callsverify_beacon_token(token)to validate the token. However, line 175 passesdata.session_id(a plain identifier string) instead of a beacon token. This will causeverify_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_tokenfor initial authentication (verified at line 166 to getverified_session_id) and abeacon_tokenstored in the SuggestionSession model for beacon operations. The current code extractsverified_session_idbut never uses it.Pass the session's stored
beacon_tokentoservice.beacon_saveinstead, or create a dedicatedbeacon_save_anonymousmethod in the service that bypasses beacon token re-verification.🤖 Prompt for AI Agents