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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user