Skip to main content

Authorization & RBAC

Boards implements role-based access control (RBAC) with board-scoped permissions. This allows fine-grained control over who can access and modify boards and their content.

Permission Model

Roles

RolePermissions
ownerFull control: manage members, delete board, manage generations
editorCreate/update generations, edit board metadata, invite members
viewerRead-only access to board and generations

Resources

  • Board: The main container for generations
  • Generation: AI-generated content within a board
  • Members: Users with roles on a specific board

Permission Matrix

Board Operations

OperationOwnerEditorViewerPublic
Create board✅ (becomes owner)✅ (becomes owner)✅ (becomes owner)
Read board✅ (if public)
Update board metadata
Delete board
Make board public/private

Member Management

OperationOwnerEditorViewer
View members
Add members✅ (viewer/editor only)
Remove members✅ (lower roles only)
Change member roles
Transfer ownership

Generation Operations

OperationOwnerEditorViewerPublic
Create generation
Read generation✅ (if board public)
Update generation✅ (own only)
Delete generation✅ (own only)
Cancel job✅ (own only)

Implementation

Authorization Helpers

Boards provides helper functions for checking permissions:

from boards.auth.authorization import (
require_board_role,
can_read_board,
can_edit_board,
can_manage_board,
get_user_board_role,
)

# Check if user has specific role on board
await require_board_role(db, board_id, user_id, {"owner", "editor"})

# Check read access (includes public boards)
can_read = await can_read_board(db, board_id, user_id)

# Get user's role on board (returns None if no access)
role = await get_user_board_role(db, board_id, user_id)

GraphQL Resolver Usage

import strawberry
from strawberry.types import Info
from boards.auth.authorization import can_read_board, require_board_role

@strawberry.type
class Query:
@strawberry.field
async def board(self, info: Info, id: UUID) -> Optional[Board]:
auth = info.context["auth"]

# Check read permission
if not await can_read_board(
info.context["db"],
id,
auth.user_id if auth.is_authenticated else None
):
raise PermissionError("Not authorized to read this board")

return await boards_repo.get_by_id(id)

@strawberry.type
class Mutation:
@strawberry.field
async def create_generation(
self, info: Info, board_id: UUID, input: GenerationInput
) -> Generation:
auth = info.context["auth"]

# Require editor or owner role
await require_board_role(
info.context["db"],
board_id,
auth.user_id,
{"editor", "owner"}
)

# Create generation...
return await generations_repo.create(board_id, input)

FastAPI Endpoint Usage

from fastapi import APIRouter, Depends, HTTPException
from boards.auth import get_auth_context, AuthContext
from boards.auth.authorization import can_read_board

router = APIRouter()

@router.get("/boards/{board_id}/export")
async def export_board(
board_id: UUID,
auth: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db)
):
# Check read permission
if not await can_read_board(db, board_id, auth.user_id):
raise HTTPException(status_code=403, detail="Access denied")

# Export board data...
return await export_board_data(board_id)

Multi-Tenancy

Authorization is scoped to tenants. Users can only access boards within their tenant, even if they have the same auth provider subject across different tenants.

# User lookup includes tenant isolation
user = await get_user_by_auth_info(
db,
tenant_id=auth_context.tenant_id,
auth_provider=principal["provider"],
auth_subject=principal["subject"]
)

# All board queries are tenant-scoped
boards = await db.execute(
select(Board).where(
and_(
Board.tenant_id == auth_context.tenant_id,
# ... other conditions
)
)
)

Public Boards

Boards can be marked as public, allowing read access without authentication:

@strawberry.field
async def public_boards(self, info: Info) -> List[Board]:
# No auth required - returns public boards only
return await db.execute(
select(Board).where(Board.is_public == True)
)

async def can_read_board(
db: AsyncSession,
board_id: UUID,
user_id: Optional[UUID]
) -> bool:
# Check if board is public first
board = await get_board_by_id(db, board_id)
if board and board.is_public:
return True

# Otherwise check user permissions
if not user_id:
return False

return await get_user_board_role(db, board_id, user_id) is not None

Storage Authorization

Presigned URLs for file uploads/downloads are also protected:

from boards.storage import generate_presigned_url
from boards.auth.authorization import can_read_board

@router.get("/boards/{board_id}/generations/{generation_id}/download")
async def get_download_url(
board_id: UUID,
generation_id: UUID,
auth: AuthContext = Depends(get_auth_context)
):
# Check read permission on board
if not await can_read_board(db, board_id, auth.user_id):
raise HTTPException(status_code=403, detail="Access denied")

# Generate presigned URL
url = await generate_presigned_url(
bucket="generations",
key=f"{board_id}/{generation_id}/output.png",
expiration=3600 # 1 hour
)

return {"download_url": url}

Job Progress Authorization

Server-Sent Events (SSE) for job progress also enforce authorization:

from fastapi import Request
from fastapi.responses import StreamingResponse

@router.get("/boards/{board_id}/jobs/{job_id}/progress")
async def stream_job_progress(
board_id: UUID,
job_id: UUID,
request: Request,
auth: AuthContext = Depends(get_auth_context)
):
# Check read permission
if not await can_read_board(db, board_id, auth.user_id):
raise HTTPException(status_code=403, detail="Access denied")

async def event_stream():
async for progress in job_progress_stream(job_id):
if await request.is_disconnected():
break
yield f"data: {progress.json()}\\n\\n"

return StreamingResponse(
event_stream(),
media_type="text/event-stream"
)

Testing Authorization

Unit Tests

import pytest
from boards.auth.authorization import can_read_board, require_board_role

@pytest.mark.asyncio
async def test_board_owner_can_read(db_session, sample_user, sample_board):
# Make user owner of board
await add_board_member(db_session, sample_board.id, sample_user.id, "owner")

# Test read permission
assert await can_read_board(db_session, sample_board.id, sample_user.id)

@pytest.mark.asyncio
async def test_viewer_cannot_edit(db_session, sample_user, sample_board):
# Make user viewer
await add_board_member(db_session, sample_board.id, sample_user.id, "viewer")

# Test edit permission fails
with pytest.raises(PermissionError):
await require_board_role(
db_session, sample_board.id, sample_user.id, {"editor", "owner"}
)

Integration Tests

@pytest.mark.asyncio
async def test_unauthorized_user_cannot_access_private_board(client, auth_headers):
board = await create_private_board()

response = await client.get(
f"/api/boards/{board.id}",
headers=auth_headers["different_user"]
)

assert response.status_code == 403

Security Considerations

Defense in Depth

  • Authorization checks at multiple layers (GraphQL, REST, storage, SSE)
  • Database-level tenant isolation
  • Audit logging of permission decisions
  • Rate limiting per user/tenant

Audit Trail

import logging

audit_logger = logging.getLogger("boards.audit")

async def require_board_role(
db: AsyncSession,
board_id: UUID,
user_id: UUID,
roles: set[str]
) -> None:
user_role = await get_user_board_role(db, board_id, user_id)

# Log authorization decision
audit_logger.info(
"Authorization check",
extra={
"user_id": str(user_id),
"board_id": str(board_id),
"required_roles": list(roles),
"user_role": user_role,
"result": "granted" if user_role in roles else "denied"
}
)

if user_role not in roles:
raise PermissionError(f"Required roles: {roles}, user has: {user_role}")

Common Pitfalls

Always check permissions at the API boundary:

# ❌ Bad: No permission check
@strawberry.field
async def board(self, info: Info, id: UUID) -> Board:
return await get_board_by_id(info.context["db"], id)

# ✅ Good: Permission check first
@strawberry.field
async def board(self, info: Info, id: UUID) -> Optional[Board]:
if not await can_read_board(info.context["db"], id, auth.user_id):
raise PermissionError("Access denied")
return await get_board_by_id(info.context["db"], id)

Don't leak information through error messages:

# ❌ Bad: Reveals board exists
if not board:
raise HTTPException(404, "Board not found")
if not await can_read_board(db, board_id, user_id):
raise HTTPException(403, "Access denied")

# ✅ Good: Consistent error for unauthorized access
if not board or not await can_read_board(db, board_id, user_id):
raise HTTPException(404, "Board not found")