Files
consentos/apps/api/src/routers/users.py
James Cottrill fbf26453f2 feat: initial public release
ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
2026-04-14 09:18:18 +00:00

137 lines
4.4 KiB
Python

import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.models.user import User
from src.schemas.auth import CurrentUser
from src.schemas.user import UserCreate, UserResponse, UserUpdate
from src.services.auth import hash_password
from src.services.dependencies import require_role
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> User:
"""Invite/create a new user within the current organisation."""
# Check email uniqueness
existing = await db.execute(select(User).where(User.email == body.email))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User with email '{body.email}' already exists",
)
user = User(
organisation_id=current_user.organisation_id,
email=body.email,
password_hash=hash_password(body.password),
full_name=body.full_name,
role=body.role,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
@router.get("/", response_model=list[UserResponse])
async def list_users(
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> list[User]:
"""List all active users in the current organisation."""
result = await db.execute(
select(User)
.where(
User.organisation_id == current_user.organisation_id,
User.deleted_at.is_(None),
)
.order_by(User.created_at)
)
return list(result.scalars().all())
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> User:
"""Get a specific user by ID within the current organisation."""
result = await db.execute(
select(User).where(
User.id == user_id,
User.organisation_id == current_user.organisation_id,
User.deleted_at.is_(None),
)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: uuid.UUID,
body: UserUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> User:
"""Update a user's name or role. Requires owner or admin."""
result = await db.execute(
select(User).where(
User.id == user_id,
User.organisation_id == current_user.organisation_id,
User.deleted_at.is_(None),
)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
await db.flush()
await db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user(
user_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> None:
"""Soft-delete (deactivate) a user. Requires owner or admin."""
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot deactivate yourself",
)
result = await db.execute(
select(User).where(
User.id == user_id,
User.organisation_id == current_user.organisation_id,
User.deleted_at.is_(None),
)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.deleted_at = datetime.now(UTC)
await db.flush()