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