diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 8ce84d179..caa1a2546 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -54,6 +54,7 @@ from .notes_routes import router as notes_router from .notion_add_connector_route import router as notion_add_connector_router from .obsidian_plugin_routes import router as obsidian_plugin_router from .onedrive_add_connector_route import router as onedrive_add_connector_router +from .personal_access_tokens_routes import router as personal_access_tokens_router from .prompts_routes import router as prompts_router from .public_chat_routes import router as public_chat_router from .rbac_routes import router as rbac_router @@ -113,6 +114,7 @@ router.include_router(slack_add_connector_router) router.include_router(teams_add_connector_router) router.include_router(onedrive_add_connector_router) router.include_router(obsidian_plugin_router) # Obsidian plugin push API +router.include_router(personal_access_tokens_router) # Personal access token manager router.include_router(discord_add_connector_router) router.include_router(jira_add_connector_router) router.include_router(confluence_add_connector_router) diff --git a/surfsense_backend/app/routes/personal_access_tokens_routes.py b/surfsense_backend/app/routes/personal_access_tokens_routes.py new file mode 100644 index 000000000..a7849a2fc --- /dev/null +++ b/surfsense_backend/app/routes/personal_access_tokens_routes.py @@ -0,0 +1,104 @@ +from datetime import UTC, datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.auth.context import AuthContext +from app.config import config +from app.db import PersonalAccessToken, get_async_session +from app.schemas.pat import PATCreate, PATCreated, PATRead +from app.users import require_session_context +from app.utils.pat import generate_pat, hash_pat, token_prefix + +router = APIRouter() + + +def _expires_at(expires_in_days: int | None) -> datetime | None: + max_expiry_days = config.PAT_MAX_EXPIRY_DAYS + + if max_expiry_days is not None: + if expires_in_days is None: + raise HTTPException( + status_code=400, + detail=( + "This deployment requires PATs to have an expiry of " + f"{max_expiry_days} days or less" + ), + ) + if expires_in_days > max_expiry_days: + raise HTTPException( + status_code=400, + detail=f"PAT expiry cannot exceed {max_expiry_days} days", + ) + + if expires_in_days is None: + return None + + return datetime.now(UTC) + timedelta(days=expires_in_days) + + +@router.post("/pats", response_model=PATCreated) +async def create_personal_access_token( + body: PATCreate, + session: AsyncSession = Depends(get_async_session), + auth: AuthContext = Depends(require_session_context), +) -> PATCreated: + token = generate_pat() + pat = PersonalAccessToken( + user_id=auth.user.id, + token_hash=hash_pat(token), + token_prefix=token_prefix(token), + label=body.label.strip(), + expires_at=_expires_at(body.expires_in_days), + ) + session.add(pat) + await session.commit() + await session.refresh(pat) + + return PATCreated( + id=pat.id, + label=pat.label, + token=token, + prefix=pat.token_prefix, + expires_at=pat.expires_at, + ) + + +@router.get("/pats", response_model=list[PATRead]) +async def list_personal_access_tokens( + session: AsyncSession = Depends(get_async_session), + auth: AuthContext = Depends(require_session_context), +) -> list[PATRead]: + result = await session.execute( + select(PersonalAccessToken) + .where(PersonalAccessToken.user_id == auth.user.id) + .order_by(PersonalAccessToken.created_at.desc()) + ) + return [ + PATRead( + id=pat.id, + label=pat.label, + prefix=pat.token_prefix, + expires_at=pat.expires_at, + last_used_at=pat.last_used_at, + created_at=pat.created_at, + ) + for pat in result.scalars().all() + ] + + +@router.delete("/pats/{pat_id}", status_code=204) +async def delete_personal_access_token( + pat_id: int, + session: AsyncSession = Depends(get_async_session), + auth: AuthContext = Depends(require_session_context), +) -> None: + await session.execute( + delete(PersonalAccessToken).where( + PersonalAccessToken.id == pat_id, + PersonalAccessToken.user_id == auth.user.id, + ) + ) + await session.commit() diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 7b508a132..1566310e1 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -104,6 +104,7 @@ from .search_source_connector import ( SearchSourceConnectorUpdate, ) from .search_space import ( + SearchSpaceApiAccessUpdate, SearchSpaceBase, SearchSpaceCreate, SearchSpaceRead, @@ -243,6 +244,7 @@ __all__ = [ "SearchSourceConnectorUpdate", # Search space schemas "SearchSpaceBase", + "SearchSpaceApiAccessUpdate", "SearchSpaceCreate", "SearchSpaceRead", "SearchSpaceUpdate", diff --git a/surfsense_backend/app/schemas/pat.py b/surfsense_backend/app/schemas/pat.py new file mode 100644 index 000000000..a4f70e21e --- /dev/null +++ b/surfsense_backend/app/schemas/pat.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class PATCreate(BaseModel): + label: str = Field(min_length=1, max_length=120) + expires_in_days: int | None = Field(default=None, gt=0) + + +class PATCreated(BaseModel): + id: int + label: str + token: str + prefix: str + expires_at: datetime | None = None + + +class PATRead(BaseModel): + id: int + label: str + prefix: str + expires_at: datetime | None = None + last_used_at: datetime | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True)