feat: add personal access token API routes

This commit is contained in:
Anish Sarkar 2026-06-19 20:26:56 +05:30
parent cddfb3660b
commit 608facd7e7
4 changed files with 135 additions and 0 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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",

View file

@ -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)