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

View File

@@ -11,7 +11,7 @@ from app.models.document import Document, ReasoningType
from app.models.folder import Folder
from app.models.project import Project
from app.models.tag import DocumentTag, Tag
from app.routers.auth import get_current_agent
from app.routers.auth import get_current_agent, get_current_agent_or_api_token
from app.schemas.document import (
DocumentBriefResponse,
DocumentContentUpdate,
@@ -178,23 +178,73 @@ async def document_to_response(db: AsyncSession, doc: Document) -> DocumentRespo
)
def _can_access_document(api_role: str | None, doc_agent_type: str | None, require_write: bool = False) -> bool:
"""
Check if a role can access a document based on agent_type.
Rules:
- JWT tokens (api_role is None) have full access via project ownership check
- researcher: can access 'research' and 'general' documents
- developer: can access 'development' and 'general' documents
- viewer: can only read (handled elsewhere), not create/modify
- admin: full access (but admin is a JWT role, not API token role)
For write operations, viewer is denied.
"""
if api_role is None:
# JWT token - access is controlled by project ownership
return True
# API token role-based access
doc_type = doc_agent_type or 'general'
if require_write:
# Viewers cannot create/update/delete
if api_role == "viewer":
return False
# Researchers can only write to research and general
if api_role == "researcher":
return doc_type in ("research", "general")
# Developers can only write to development and general
if api_role == "developer":
return doc_type in ("development", "general")
else:
# Read access - viewers can read research, development, and general
if api_role == "viewer":
return doc_type in ("research", "development", "general")
# Researchers can only read research and general
if api_role == "researcher":
return doc_type in ("research", "general")
# Developers can only read development and general
if api_role == "developer":
return doc_type in ("development", "general")
return False
@router.get("/api/v1/projects/{project_id}/documents", response_model=DocumentListResponse)
async def list_documents(
request: Request,
project_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
agent, api_role = await get_current_agent_or_api_token(request, db)
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
else:
# API tokens don't have project-level access control here
# Access is controlled at document level via agent_type
pass
result = await db.execute(
select(Document).where(
@@ -206,6 +256,9 @@ async def list_documents(
responses = []
for doc in docs:
# Apply role-based filtering for API tokens
if api_role is not None and not _can_access_document(api_role, doc.agent_type, require_write=False):
continue
tags = await get_document_tags(db, doc.id)
responses.append(DocumentBriefResponse(
id=doc.id,
@@ -228,17 +281,28 @@ async def create_document(
payload: DocumentCreate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
agent, api_role = await get_current_agent_or_api_token(request, db)
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Determine agent_type for the document
doc_agent_type = payload.agent_type or "general"
if doc_agent_type not in ("research", "development", "general"):
raise HTTPException(status_code=400, detail="Invalid agent_type")
# Check role-based write access
if api_role is not None and not _can_access_document(api_role, doc_agent_type, require_write=True):
raise HTTPException(status_code=403, detail="Forbidden")
folder_path = None
if payload.folder_id:
@@ -264,6 +328,7 @@ async def create_document(
project_id=project_id,
folder_id=payload.folder_id,
path=path,
agent_type=doc_agent_type,
)
db.add(doc)
await db.flush()
@@ -276,7 +341,7 @@ async def get_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
agent, api_role = await get_current_agent_or_api_token(request, db)
result = await db.execute(
select(Document).where(
@@ -288,15 +353,21 @@ async def get_document(
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Document not found")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Document not found")
else:
# API tokens check role-based access
if not _can_access_document(api_role, doc.agent_type, require_write=False):
raise HTTPException(status_code=403, detail="Forbidden")
return await document_to_response(db, doc)
@@ -308,7 +379,7 @@ async def update_document(
payload: DocumentUpdate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
agent, api_role = await get_current_agent_or_api_token(request, db)
result = await db.execute(
select(Document).where(
@@ -320,15 +391,21 @@ async def update_document(
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
else:
# API tokens check role-based write access
if not _can_access_document(api_role, doc.agent_type, require_write=True):
raise HTTPException(status_code=403, detail="Forbidden")
if payload.title is not None:
doc.title = payload.title
@@ -360,7 +437,7 @@ async def delete_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
agent, api_role = await get_current_agent_or_api_token(request, db)
result = await db.execute(
select(Document).where(
@@ -372,15 +449,21 @@ async def delete_document(
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
else:
# API tokens check role-based write access
if not _can_access_document(api_role, doc.agent_type, require_write=True):
raise HTTPException(status_code=403, detail="Forbidden")
doc.is_deleted = True
doc.deleted_at = datetime.utcnow()
@@ -401,7 +484,7 @@ async def update_document_content(
Phase 2: Now supports both TipTap JSON and Markdown formats via the 'format' field.
Also backward-compatible with legacy string content (treated as markdown).
"""
agent = await get_current_agent(request, db)
agent, api_role = await get_current_agent_or_api_token(request, db)
result = await db.execute(
select(Document).where(
@@ -413,15 +496,21 @@ async def update_document_content(
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
else:
# API tokens check role-based write access
if not _can_access_document(api_role, doc.agent_type, require_write=True):
raise HTTPException(status_code=403, detail="Forbidden")
# Determine actual format based on content type (backward compatibility)
# If content is a string, treat as markdown regardless of format field
@@ -465,7 +554,7 @@ async def restore_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
agent, api_role = await get_current_agent_or_api_token(request, db)
result = await db.execute(
select(Document).where(
@@ -477,15 +566,21 @@ async def restore_document(
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
else:
# API tokens check role-based write access
if not _can_access_document(api_role, doc.agent_type, require_write=True):
raise HTTPException(status_code=403, detail="Forbidden")
doc.is_deleted = False
doc.deleted_at = None
@@ -501,7 +596,7 @@ async def assign_tags(
payload: DocumentTagsAssign,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
agent, api_role = await get_current_agent_or_api_token(request, db)
result = await db.execute(
select(Document).where(
@@ -513,15 +608,21 @@ async def assign_tags(
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
else:
# API tokens check role-based write access
if not _can_access_document(api_role, doc.agent_type, require_write=True):
raise HTTPException(status_code=403, detail="Forbidden")
for tag_id in payload.tag_ids:
tag_result = await db.execute(
@@ -555,7 +656,7 @@ async def remove_tag(
tag_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
agent, api_role = await get_current_agent_or_api_token(request, db)
result = await db.execute(
select(Document).where(
@@ -567,15 +668,21 @@ async def remove_tag(
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
else:
# API tokens check role-based write access
if not _can_access_document(api_role, doc.agent_type, require_write=True):
raise HTTPException(status_code=403, detail="Forbidden")
await db.execute(
delete(DocumentTag).where(
@@ -596,9 +703,9 @@ async def _get_doc_with_access(
document_id: str,
db: AsyncSession,
require_write: bool = False,
) -> tuple[Document, bool]:
"""Get document and check access. Returns (doc, has_access)."""
agent = await get_current_agent(request, db)
) -> tuple[Document, str | None]:
"""Get document and check access. Returns (doc, api_role)."""
agent, api_role = await get_current_agent_or_api_token(request, db)
result = await db.execute(
select(Document).where(
@@ -610,17 +717,23 @@ async def _get_doc_with_access(
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
# JWT tokens check project ownership
if api_role is None:
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
else:
# API tokens check role-based access
if not _can_access_document(api_role, doc.agent_type, require_write=require_write):
raise HTTPException(status_code=403, detail="Forbidden")
return doc, True
return doc, api_role
@router.get("/api/v1/documents/{document_id}/reasoning")