feat: Add role-based API tokens for Claudia Docs

- Add api_tokens table with role-based access (researcher, developer, viewer)
- Add POST /auth/token/generate endpoint for creating tokens
- Add GET /auth/tokens endpoint for listing user's tokens
- Add DELETE /auth/tokens/{token_id} endpoint for revoking tokens
- Add agent_type field to documents (research, development, general)
- Implement role-based access control for documents:
  - researcher: access to research and general documents
  - developer: access to development and general documents
  - viewer: read-only access
- Update document model and schemas with agent_type field
- Add comprehensive tests for API token functionality
- All existing tests pass (73 total)
This commit is contained in:
Motoko
2026-03-31 01:46:51 +00:00
parent 5beac2d673
commit 204badb964
10 changed files with 770 additions and 97 deletions

View File

@@ -3,9 +3,19 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.agent import Agent
from app.schemas.auth import AgentCreate, AgentLogin, AgentResponse, RefreshResponse, TokenResponse
from app.schemas.auth import (
AgentCreate,
AgentLogin,
AgentResponse,
ApiTokenCreate,
ApiTokenGenerateResponse,
ApiTokenResponse,
RefreshResponse,
TokenResponse,
)
from app.services import auth as auth_service
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@@ -40,7 +50,7 @@ def _clear_refresh_cookie(response: Response):
async def get_current_agent(request: Request, db: AsyncSession) -> Agent:
"""Get the current authenticated agent from request."""
"""Get the current authenticated agent from request (JWT only)."""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Not authenticated")
@@ -67,6 +77,46 @@ async def get_current_agent(request: Request, db: AsyncSession) -> Agent:
return agent
async def get_current_agent_or_api_token(request: Request, db: AsyncSession) -> tuple[Agent | None, str | None]:
"""
Get the current authenticated agent or validate an API token.
Returns (agent, api_role) where agent is None for API tokens.
For JWT tokens: (agent, None)
For API tokens: (None, role)
Raises HTTPException if neither is valid.
"""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Not authenticated")
token = auth_header[7:]
# First try JWT
payload = auth_service.decode_token(token)
if payload:
jti = payload.get("jti")
if jti:
is_blocked = await auth_service.is_token_blocklisted(db, jti)
if is_blocked:
raise HTTPException(status_code=401, detail="Token revoked")
agent_id = payload.get("sub")
if agent_id:
agent = await auth_service.get_agent_by_id(db, agent_id)
if agent:
return agent, None
# Try API token
result = await auth_service.verify_api_token(db, token)
if result:
agent_id, role = result
agent = await auth_service.get_agent_by_id(db, agent_id)
if agent:
return agent, role
raise HTTPException(status_code=401, detail="Invalid token")
@router.post("/register", response_model=AgentResponse, status_code=201)
async def register(payload: AgentCreate, db: AsyncSession = Depends(get_db)):
if settings.DISABLE_REGISTRATION:
@@ -168,3 +218,54 @@ async def logout_all(request: Request, response: Response, db: AsyncSession = De
_clear_refresh_cookie(response)
return Response(status_code=204)
# =============================================================================
# Role-based API Token Management
# =============================================================================
@router.post("/token/generate", response_model=ApiTokenGenerateResponse, status_code=201)
async def generate_token(
request: Request,
payload: ApiTokenCreate,
db: AsyncSession = Depends(get_db),
):
"""Generate a new API token with a specific role."""
# Only admin agents can create API tokens
agent = await get_current_agent(request, db)
if agent.role != "admin":
raise HTTPException(status_code=403, detail="Only admin agents can create API tokens")
raw_token, token_record = await auth_service.create_api_token(
db, payload.name, payload.role, agent.id
)
return ApiTokenGenerateResponse(
token=raw_token,
name=token_record.name,
role=token_record.role,
)
@router.get("/tokens", response_model=list[ApiTokenResponse])
async def list_tokens(
request: Request,
db: AsyncSession = Depends(get_db),
):
"""List all API tokens for the current agent."""
agent = await get_current_agent(request, db)
tokens = await auth_service.list_api_tokens(db, agent.id)
return [ApiTokenResponse.model_validate(t) for t in tokens]
@router.delete("/tokens/{token_id}", status_code=204)
async def revoke_token(
request: Request,
token_id: str,
db: AsyncSession = Depends(get_db),
):
"""Revoke an API token."""
agent = await get_current_agent(request, db)
success = await auth_service.revoke_api_token(db, token_id, agent.id)
if not success:
raise HTTPException(status_code=404, detail="Token not found")
return None