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.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

View File

View File

@@ -0,0 +1,108 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.config.settings import get_settings
from src.db import get_db
from src.models.user import User
from src.schemas.auth import CurrentUser, LoginRequest, RefreshRequest, TokenResponse
from src.services.auth import (
create_access_token,
create_refresh_token,
decode_token,
verify_password,
)
from src.services.dependencies import get_current_user
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
"""Authenticate a user with email and password, return JWT tokens."""
result = await db.execute(
select(User).where(User.email == body.email, User.deleted_at.is_(None))
)
user = result.scalar_one_or_none()
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
settings = get_settings()
access_token = create_access_token(
user_id=user.id,
organisation_id=user.organisation_id,
role=user.role,
email=user.email,
)
refresh_token = create_refresh_token(
user_id=user.id,
organisation_id=user.organisation_id,
)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.jwt_access_token_expire_minutes * 60,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(
body: RefreshRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
"""Exchange a valid refresh token for a new access/refresh token pair."""
try:
payload = decode_token(body.refresh_token)
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
) from exc
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token is not a refresh token",
)
user_id = uuid.UUID(payload["sub"])
result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User no longer exists",
)
settings = get_settings()
access_token = create_access_token(
user_id=user.id,
organisation_id=user.organisation_id,
role=user.role,
email=user.email,
)
new_refresh_token = create_refresh_token(
user_id=user.id,
organisation_id=user.organisation_id,
)
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
expires_in=settings.jwt_access_token_expire_minutes * 60,
)
@router.get("/me", response_model=CurrentUser)
async def get_me(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""Return the currently authenticated user's profile from the JWT."""
return current_user

View File

@@ -0,0 +1,135 @@
"""Compliance checking endpoints.
Evaluates a site's configuration against regulatory frameworks (GDPR, CNIL,
CCPA, ePrivacy, LGPD) and returns per-framework compliance reports with scores,
issues, and recommendations.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.models.cookie import Cookie
from src.models.site import Site
from src.models.site_config import SiteConfig
from src.schemas.compliance import (
ComplianceCheckRequest,
ComplianceCheckResponse,
Framework,
)
from src.services.compliance import (
SiteContext,
calculate_overall_score,
run_compliance_check,
)
from src.services.dependencies import get_current_user
router = APIRouter(prefix="/compliance", tags=["compliance"])
async def _build_site_context(
site_id: uuid.UUID,
db: AsyncSession,
) -> SiteContext:
"""Load site config and cookie stats to build a SiteContext."""
# Fetch site config
result = await db.execute(
select(SiteConfig).where(
SiteConfig.site_id == site_id,
SiteConfig.deleted_at.is_(None),
)
)
config = result.scalar_one_or_none()
# Fetch cookie statistics
total_q = await db.execute(
select(func.count()).select_from(Cookie).where(Cookie.site_id == site_id)
)
total_cookies = total_q.scalar() or 0
uncat_q = await db.execute(
select(func.count())
.select_from(Cookie)
.where(
Cookie.site_id == site_id,
Cookie.category_id.is_(None),
)
)
uncategorised_cookies = uncat_q.scalar() or 0
if config is None:
return SiteContext(
total_cookies=total_cookies,
uncategorised_cookies=uncategorised_cookies,
)
banner_config = config.banner_config or {}
return SiteContext(
blocking_mode=config.blocking_mode,
regional_modes=config.regional_modes,
tcf_enabled=config.tcf_enabled,
gcm_enabled=config.gcm_enabled,
consent_expiry_days=config.consent_expiry_days,
privacy_policy_url=config.privacy_policy_url,
display_mode=config.display_mode,
banner_config=config.banner_config,
total_cookies=total_cookies,
uncategorised_cookies=uncategorised_cookies,
has_reject_button=banner_config.get("show_reject_all", True),
has_granular_choices=banner_config.get("show_category_toggles", True),
has_cookie_wall=banner_config.get("cookie_wall", False),
pre_ticked_boxes=banner_config.get("pre_ticked", False),
)
@router.post(
"/check/{site_id}",
response_model=ComplianceCheckResponse,
status_code=status.HTTP_200_OK,
)
async def check_compliance(
site_id: uuid.UUID,
body: ComplianceCheckRequest | None = None,
db: AsyncSession = Depends(get_db),
_user=Depends(get_current_user),
) -> ComplianceCheckResponse:
"""Run compliance checks against a site's configuration."""
# Verify site exists
site_result = await db.execute(
select(Site).where(Site.id == site_id, Site.deleted_at.is_(None))
)
site = site_result.scalar_one_or_none()
if site is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site not found",
)
ctx = await _build_site_context(site_id, db)
frameworks = body.frameworks if body else None
results = run_compliance_check(ctx, frameworks)
overall_score = calculate_overall_score(results)
return ComplianceCheckResponse(
site_id=str(site_id),
results=results,
overall_score=overall_score,
)
@router.get("/frameworks", response_model=list[dict])
async def list_frameworks() -> list[dict]:
"""List all available compliance frameworks."""
return [
{"id": fw.value, "name": fw.value.upper(), "description": desc}
for fw, desc in [
(Framework.GDPR, "EU General Data Protection Regulation"),
(Framework.CNIL, "French Data Protection Authority (stricter GDPR)"),
(Framework.CCPA, "California Consumer Privacy Act / CPRA"),
(Framework.EPRIVACY, "EU ePrivacy Directive"),
(Framework.LGPD, "Brazilian General Data Protection Law"),
]
]

View File

@@ -0,0 +1,324 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.extensions.registry import get_registry
from src.models.org_config import OrgConfig
from src.models.site import Site
from src.models.site_config import SiteConfig
from src.models.site_group_config import SiteGroupConfig
from src.schemas.auth import CurrentUser
from src.schemas.site import SiteConfigResponse
from src.services.config_resolver import (
CONFIG_FIELDS,
build_public_config,
orm_to_config_dict,
resolve_config,
)
from src.services.dependencies import require_role
from src.services.geoip import detect_region
from src.services.publisher import publish_site_config
router = APIRouter(prefix="/config", tags=["config"])
@router.get("/sites/{site_id}", response_model=SiteConfigResponse)
async def get_public_site_config(
site_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> SiteConfig:
"""Public endpoint: retrieve site config for the banner script. No auth required."""
result = await db.execute(
select(SiteConfig)
.join(Site)
.where(
SiteConfig.site_id == site_id,
Site.is_active.is_(True),
Site.deleted_at.is_(None),
)
)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site configuration not found",
)
return config
@router.get("/sites/{site_id}/resolved")
async def get_resolved_config(
site_id: uuid.UUID,
region: str | None = None,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Public endpoint: retrieve fully resolved config with regional overrides applied.
Applies the full cascade: System → Org → Group → Site → Regional.
"""
result = await db.execute(
select(SiteConfig)
.join(Site)
.where(
SiteConfig.site_id == site_id,
Site.is_active.is_(True),
Site.deleted_at.is_(None),
)
)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site configuration not found",
)
config_dict = orm_to_config_dict(config, include_id=True)
# Load org defaults via the site
org_id = await _get_site_org_id(site_id, db)
org_defaults = await _load_org_defaults(org_id, db) if org_id else None
# Load site group defaults
group_id = await _get_site_group_id(site_id, db)
group_defaults = await _load_group_defaults(group_id, db) if group_id else None
resolved = resolve_config(
config_dict,
org_defaults=org_defaults,
group_defaults=group_defaults,
region=region,
)
return build_public_config(str(site_id), resolved)
@router.get("/sites/{site_id}/geo-resolved")
async def get_geo_resolved_config(
site_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Public endpoint: resolve config using the visitor's detected region.
Detects the visitor's region from CDN headers or IP geolocation,
then applies regional blocking mode overrides automatically.
Uses the full cascade: System → Org → Group → Site → Regional.
"""
result = await db.execute(
select(SiteConfig)
.join(Site)
.where(
SiteConfig.site_id == site_id,
Site.is_active.is_(True),
Site.deleted_at.is_(None),
)
)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site configuration not found",
)
# Detect region from request
geo = await detect_region(request)
config_dict = orm_to_config_dict(config, include_id=True)
org_id = await _get_site_org_id(site_id, db)
org_defaults = await _load_org_defaults(org_id, db) if org_id else None
group_id = await _get_site_group_id(site_id, db)
group_defaults = await _load_group_defaults(group_id, db) if group_id else None
resolved = resolve_config(
config_dict,
org_defaults=org_defaults,
group_defaults=group_defaults,
region=geo.region,
)
public = build_public_config(str(site_id), resolved)
# Include detected geo info so the banner can use it
public["detected_country"] = geo.country_code
public["detected_region"] = geo.region
return public
@router.get("/geo")
async def get_visitor_geo(request: Request) -> dict:
"""Public endpoint: return the detected region for the current visitor.
Useful for banner scripts that need to know the region before
fetching the full config.
"""
geo = await detect_region(request)
return {
"country_code": geo.country_code,
"region": geo.region,
}
@router.get("/sites/{site_id}/inheritance")
async def get_config_inheritance(
site_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Return the full config inheritance chain for a site.
Shows the value at each level so the UI can display where each setting
comes from: system, org, group, or site.
"""
from src.services.config_resolver import SYSTEM_DEFAULTS
result = await db.execute(
select(SiteConfig)
.join(Site)
.where(
SiteConfig.site_id == site_id,
Site.organisation_id == current_user.organisation_id,
Site.deleted_at.is_(None),
)
)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site configuration not found",
)
site_dict = orm_to_config_dict(config)
org_defaults = await _load_org_defaults(current_user.organisation_id, db)
group_id = await _get_site_group_id(site_id, db)
group_defaults = await _load_group_defaults(group_id, db) if group_id else None
resolved = resolve_config(
site_dict,
org_defaults=org_defaults,
group_defaults=group_defaults,
)
# For each config field, determine the source
sources: dict[str, dict] = {}
for field in CONFIG_FIELDS:
site_val = site_dict.get(field)
group_val = group_defaults.get(field) if group_defaults else None
org_val = org_defaults.get(field) if org_defaults else None
system_val = SYSTEM_DEFAULTS.get(field)
# Determine effective source (highest priority non-None wins)
if site_val is not None:
source = "site"
elif group_val is not None:
source = "group"
elif org_val is not None:
source = "org"
elif system_val is not None:
source = "system"
else:
source = "system"
sources[field] = {
"resolved_value": resolved.get(field),
"source": source,
"site_value": site_val,
"group_value": group_val,
"org_value": org_val,
"system_value": system_val,
}
return {
"site_id": str(site_id),
"site_group_id": str(group_id) if group_id else None,
"fields": sources,
}
@router.post("/sites/{site_id}/publish")
async def publish_config(
site_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Publish fully-resolved site config to CDN. Requires admin role."""
result = await db.execute(
select(SiteConfig)
.join(Site)
.where(
SiteConfig.site_id == site_id,
Site.organisation_id == current_user.organisation_id,
Site.deleted_at.is_(None),
)
)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site configuration not found",
)
config_dict = orm_to_config_dict(config, include_id=True)
org_defaults = await _load_org_defaults(current_user.organisation_id, db)
group_id = await _get_site_group_id(site_id, db)
group_defaults = await _load_group_defaults(group_id, db) if group_id else None
resolved = resolve_config(
config_dict,
org_defaults=org_defaults,
group_defaults=group_defaults,
)
# Allow extensions to enrich the published config (e.g. A/B test data)
registry = get_registry()
for enricher in registry.config_enrichers:
await enricher(site_id, db, resolved)
publish_result = await publish_site_config(str(site_id), resolved)
if not publish_result.success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Publish failed: {publish_result.error}",
)
return {
"published": True,
"path": publish_result.path,
"published_at": publish_result.published_at,
}
# ── Helpers ──────────────────────────────────────────────────────────
async def _get_site_org_id(site_id: uuid.UUID, db: AsyncSession) -> uuid.UUID | None:
"""Look up the organisation_id for a site."""
result = await db.execute(select(Site.organisation_id).where(Site.id == site_id))
return result.scalar_one_or_none()
async def _get_site_group_id(site_id: uuid.UUID, db: AsyncSession) -> uuid.UUID | None:
"""Look up the site_group_id for a site."""
result = await db.execute(select(Site.site_group_id).where(Site.id == site_id))
return result.scalar_one_or_none()
async def _load_org_defaults(organisation_id: uuid.UUID, db: AsyncSession) -> dict | None:
"""Load the org-level config defaults, or None if not set."""
result = await db.execute(select(OrgConfig).where(OrgConfig.organisation_id == organisation_id))
org_config = result.scalar_one_or_none()
if org_config is None:
return None
return orm_to_config_dict(org_config)
async def _load_group_defaults(group_id: uuid.UUID, db: AsyncSession) -> dict | None:
"""Load the site-group-level config defaults, or None if not set."""
result = await db.execute(
select(SiteGroupConfig).where(SiteGroupConfig.site_group_id == group_id)
)
group_config = result.scalar_one_or_none()
if group_config is None:
return None
return orm_to_config_dict(group_config)

View File

@@ -0,0 +1,125 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.extensions.registry import get_registry
from src.models.consent import ConsentRecord
from src.models.site import Site
from src.schemas.auth import CurrentUser
from src.schemas.consent import (
ConsentRecordCreate,
ConsentRecordResponse,
ConsentVerifyResponse,
)
from src.services.dependencies import require_role
from src.services.pseudonymisation import pseudonymise
router = APIRouter(prefix="/consent", tags=["consent"])
@router.post("/", response_model=ConsentRecordResponse, status_code=status.HTTP_201_CREATED)
async def record_consent(
body: ConsentRecordCreate,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ConsentRecord:
"""Record a consent event from the banner. Public endpoint (no auth required)."""
# Pseudonymise IP and user agent with HMAC so the resulting values
# cannot be reversed without the server-side secret.
client_ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
record = ConsentRecord(
site_id=body.site_id,
visitor_id=body.visitor_id,
ip_hash=pseudonymise(client_ip),
user_agent_hash=pseudonymise(user_agent),
action=body.action,
categories_accepted=body.categories_accepted,
categories_rejected=body.categories_rejected,
tc_string=body.tc_string,
gcm_state=body.gcm_state,
page_url=body.page_url,
country_code=body.country_code,
region_code=body.region_code,
)
db.add(record)
await db.flush()
await db.refresh(record)
# Invoke any registered post-record hooks (EE consent receipts, etc.)
for hook in get_registry().consent_record_hooks:
await hook(db, record)
return record
async def _load_record_for_org(
consent_id: uuid.UUID,
current_user: CurrentUser,
db: AsyncSession,
) -> ConsentRecord:
"""Load a consent record and enforce tenant isolation.
The record's site must belong to the caller's organisation. A record
from another tenant returns 404 rather than 403 so we don't leak
existence across tenants.
"""
stmt = (
select(ConsentRecord)
.join(Site, Site.id == ConsentRecord.site_id)
.where(
ConsentRecord.id == consent_id,
Site.organisation_id == current_user.organisation_id,
Site.deleted_at.is_(None),
)
)
record = (await db.execute(stmt)).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Consent record not found",
)
return record
@router.get("/{consent_id}", response_model=ConsentRecordResponse)
async def get_consent(
consent_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> ConsentRecord:
"""Retrieve a consent record by ID.
Requires authentication and tenant membership. Consent records
contain PII-adjacent data (hashed IP, page URL, category decisions)
and must not be readable by anyone holding a record UUID.
"""
return await _load_record_for_org(consent_id, current_user, db)
@router.get("/verify/{consent_id}", response_model=ConsentVerifyResponse)
async def verify_consent(
consent_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Verify that a consent record exists (audit proof).
Same tenant-scoped auth as :func:`get_consent` — proof of consent
is only meaningful to the organisation that owns the site, and
leaking existence to arbitrary callers enables enumeration.
"""
record = await _load_record_for_org(consent_id, current_user, db)
return {
"id": record.id,
"site_id": record.site_id,
"visitor_id": record.visitor_id,
"action": record.action,
"categories_accepted": record.categories_accepted,
"consented_at": record.consented_at,
"valid": True,
}

View File

@@ -0,0 +1,582 @@
"""Cookie category, cookie, and allow-list management endpoints."""
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.models.cookie import Cookie, CookieAllowListEntry, CookieCategory, KnownCookie
from src.models.site import Site
from src.schemas.auth import CurrentUser
from src.schemas.cookie import (
AllowListEntryCreate,
AllowListEntryResponse,
AllowListEntryUpdate,
ClassificationResultResponse,
ClassifySingleRequest,
ClassifySiteResponse,
CookieCategoryResponse,
CookieCreate,
CookieResponse,
CookieUpdate,
KnownCookieCreate,
KnownCookieResponse,
KnownCookieUpdate,
ReviewStatus,
)
from src.services.classification import classify_single_cookie, classify_site_cookies
from src.services.dependencies import get_current_user, require_role
router = APIRouter(prefix="/cookies", tags=["cookies"])
# ── Cookie categories (read-only, seeded by migration) ──────────────
@router.get("/categories", response_model=list[CookieCategoryResponse])
async def list_categories(
db: AsyncSession = Depends(get_db),
) -> list[CookieCategory]:
"""List all cookie categories. Public endpoint used by banner and admin."""
result = await db.execute(select(CookieCategory).order_by(CookieCategory.display_order))
return list(result.scalars().all())
@router.get("/categories/{category_id}", response_model=CookieCategoryResponse)
async def get_category(
category_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> CookieCategory:
"""Get a single cookie category by ID."""
result = await db.execute(select(CookieCategory).where(CookieCategory.id == category_id))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
return category
# ── Cookies per site ─────────────────────────────────────────────────
async def _get_org_site(
site_id: uuid.UUID,
current_user: CurrentUser,
db: AsyncSession,
) -> Site:
"""Fetch a site ensuring it belongs to the user's organisation."""
result = await db.execute(
select(Site).where(
Site.id == site_id,
Site.organisation_id == current_user.organisation_id,
Site.deleted_at.is_(None),
)
)
site = result.scalar_one_or_none()
if not site:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
return site
@router.get(
"/sites/{site_id}",
response_model=list[CookieResponse],
)
async def list_cookies(
site_id: uuid.UUID,
review_status: ReviewStatus | None = Query(None),
category_id: uuid.UUID | None = Query(None),
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[Cookie]:
"""List cookies discovered on a site, with optional filters."""
await _get_org_site(site_id, current_user, db)
query = select(Cookie).where(Cookie.site_id == site_id)
if review_status:
query = query.where(Cookie.review_status == review_status.value)
if category_id:
query = query.where(Cookie.category_id == category_id)
query = query.order_by(Cookie.name)
result = await db.execute(query)
return list(result.scalars().all())
@router.post(
"/sites/{site_id}",
response_model=CookieResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_cookie(
site_id: uuid.UUID,
body: CookieCreate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> Cookie:
"""Create a cookie record for a site (manual entry or from scanner)."""
await _get_org_site(site_id, current_user, db)
# Validate category if provided
if body.category_id:
cat = await db.execute(select(CookieCategory).where(CookieCategory.id == body.category_id))
if not cat.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid category_id",
)
cookie = Cookie(
site_id=site_id,
**body.model_dump(),
first_seen_at=datetime.now(UTC).isoformat(),
last_seen_at=datetime.now(UTC).isoformat(),
)
db.add(cookie)
await db.flush()
await db.refresh(cookie)
return cookie
@router.get("/sites/{site_id}/summary")
async def cookie_summary(
site_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Get a summary of cookies for a site (counts by status and category)."""
await _get_org_site(site_id, current_user, db)
# Count by review status
status_result = await db.execute(
select(Cookie.review_status, func.count(Cookie.id))
.where(Cookie.site_id == site_id)
.group_by(Cookie.review_status)
)
by_status = {row[0]: row[1] for row in status_result.all()}
# Count by category
cat_result = await db.execute(
select(CookieCategory.slug, func.count(Cookie.id))
.outerjoin(Cookie, Cookie.category_id == CookieCategory.id)
.where(Cookie.site_id == site_id)
.group_by(CookieCategory.slug)
)
by_category = {row[0]: row[1] for row in cat_result.all()}
# Uncategorised count
uncat_result = await db.execute(
select(func.count(Cookie.id)).where(Cookie.site_id == site_id, Cookie.category_id.is_(None))
)
uncategorised = uncat_result.scalar() or 0
return {
"total": sum(by_status.values()),
"by_status": by_status,
"by_category": by_category,
"uncategorised": uncategorised,
}
# ── Allow-list per site ──────────────────────────────────────────────
# (Must be defined before {cookie_id} routes to avoid path conflicts)
@router.get(
"/sites/{site_id}/allow-list",
response_model=list[AllowListEntryResponse],
)
async def list_allow_list(
site_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[CookieAllowListEntry]:
"""List all allow-list entries for a site."""
await _get_org_site(site_id, current_user, db)
result = await db.execute(
select(CookieAllowListEntry)
.where(CookieAllowListEntry.site_id == site_id)
.order_by(CookieAllowListEntry.name_pattern)
)
return list(result.scalars().all())
@router.post(
"/sites/{site_id}/allow-list",
response_model=AllowListEntryResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_allow_list_entry(
site_id: uuid.UUID,
body: AllowListEntryCreate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> CookieAllowListEntry:
"""Add a cookie pattern to the allow-list for a site."""
await _get_org_site(site_id, current_user, db)
# Validate category
cat = await db.execute(select(CookieCategory).where(CookieCategory.id == body.category_id))
if not cat.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid category_id",
)
entry = CookieAllowListEntry(
site_id=site_id,
**body.model_dump(),
)
db.add(entry)
await db.flush()
await db.refresh(entry)
return entry
@router.patch(
"/sites/{site_id}/allow-list/{entry_id}",
response_model=AllowListEntryResponse,
)
async def update_allow_list_entry(
site_id: uuid.UUID,
entry_id: uuid.UUID,
body: AllowListEntryUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> CookieAllowListEntry:
"""Update an allow-list entry."""
await _get_org_site(site_id, current_user, db)
result = await db.execute(
select(CookieAllowListEntry).where(
CookieAllowListEntry.id == entry_id,
CookieAllowListEntry.site_id == site_id,
)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Allow-list entry not found",
)
updates = body.model_dump(exclude_unset=True)
if "category_id" in updates and updates["category_id"] is not None:
cat = await db.execute(
select(CookieCategory).where(CookieCategory.id == updates["category_id"])
)
if not cat.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid category_id",
)
for field, value in updates.items():
setattr(entry, field, value)
entry.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(entry)
return entry
@router.delete(
"/sites/{site_id}/allow-list/{entry_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_allow_list_entry(
site_id: uuid.UUID,
entry_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> None:
"""Remove an entry from the allow-list."""
await _get_org_site(site_id, current_user, db)
result = await db.execute(
select(CookieAllowListEntry).where(
CookieAllowListEntry.id == entry_id,
CookieAllowListEntry.site_id == site_id,
)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Allow-list entry not found",
)
await db.delete(entry)
# ── Individual cookie by ID (must come after /summary and /allow-list) ──
@router.get("/sites/{site_id}/{cookie_id}", response_model=CookieResponse)
async def get_cookie(
site_id: uuid.UUID,
cookie_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Cookie:
"""Get a single cookie by ID."""
await _get_org_site(site_id, current_user, db)
result = await db.execute(
select(Cookie).where(Cookie.id == cookie_id, Cookie.site_id == site_id)
)
cookie = result.scalar_one_or_none()
if not cookie:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cookie not found")
return cookie
@router.patch("/sites/{site_id}/{cookie_id}", response_model=CookieResponse)
async def update_cookie(
site_id: uuid.UUID,
cookie_id: uuid.UUID,
body: CookieUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> Cookie:
"""Update a cookie record (e.g. assign category, change review status)."""
await _get_org_site(site_id, current_user, db)
result = await db.execute(
select(Cookie).where(Cookie.id == cookie_id, Cookie.site_id == site_id)
)
cookie = result.scalar_one_or_none()
if not cookie:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cookie not found")
updates = body.model_dump(exclude_unset=True)
# Validate category if being changed
if "category_id" in updates and updates["category_id"] is not None:
cat = await db.execute(
select(CookieCategory).where(CookieCategory.id == updates["category_id"])
)
if not cat.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid category_id",
)
for field, value in updates.items():
setattr(cookie, field, value)
cookie.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(cookie)
return cookie
@router.delete(
"/sites/{site_id}/{cookie_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_cookie(
site_id: uuid.UUID,
cookie_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> None:
"""Delete a cookie record."""
await _get_org_site(site_id, current_user, db)
result = await db.execute(
select(Cookie).where(Cookie.id == cookie_id, Cookie.site_id == site_id)
)
cookie = result.scalar_one_or_none()
if not cookie:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cookie not found")
await db.delete(cookie)
# ── Known cookies database ──────────────────────────────────────────
@router.get("/known", response_model=list[KnownCookieResponse])
async def list_known_cookies(
vendor: str | None = Query(None, description="Filter by vendor name"),
search: str | None = Query(None, description="Search by name pattern"),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[KnownCookie]:
"""List known cookie patterns from the shared database."""
query = select(KnownCookie).order_by(KnownCookie.name_pattern)
if vendor:
query = query.where(KnownCookie.vendor == vendor)
if search:
query = query.where(KnownCookie.name_pattern.ilike(f"%{search}%"))
result = await db.execute(query)
return list(result.scalars().all())
@router.post(
"/known",
response_model=KnownCookieResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_known_cookie(
body: KnownCookieCreate,
_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> KnownCookie:
"""Add a new pattern to the known cookies database."""
# Validate category
cat = await db.execute(select(CookieCategory).where(CookieCategory.id == body.category_id))
if not cat.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid category_id",
)
known = KnownCookie(**body.model_dump())
db.add(known)
await db.flush()
await db.refresh(known)
return known
@router.get("/known/{known_id}", response_model=KnownCookieResponse)
async def get_known_cookie(
known_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> KnownCookie:
"""Get a single known cookie pattern by ID."""
result = await db.execute(select(KnownCookie).where(KnownCookie.id == known_id))
known = result.scalar_one_or_none()
if not known:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Known cookie not found",
)
return known
@router.patch("/known/{known_id}", response_model=KnownCookieResponse)
async def update_known_cookie(
known_id: uuid.UUID,
body: KnownCookieUpdate,
_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> KnownCookie:
"""Update a known cookie pattern."""
result = await db.execute(select(KnownCookie).where(KnownCookie.id == known_id))
known = result.scalar_one_or_none()
if not known:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Known cookie not found",
)
updates = body.model_dump(exclude_unset=True)
if "category_id" in updates and updates["category_id"] is not None:
cat = await db.execute(
select(CookieCategory).where(CookieCategory.id == updates["category_id"])
)
if not cat.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid category_id",
)
for field, value in updates.items():
setattr(known, field, value)
known.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(known)
return known
@router.delete(
"/known/{known_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_known_cookie(
known_id: uuid.UUID,
_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> None:
"""Delete a known cookie pattern."""
result = await db.execute(select(KnownCookie).where(KnownCookie.id == known_id))
known = result.scalar_one_or_none()
if not known:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Known cookie not found",
)
await db.delete(known)
# ── Classification endpoints ────────────────────────────────────────
@router.post(
"/sites/{site_id}/classify",
response_model=ClassifySiteResponse,
)
async def classify_cookies(
site_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> ClassifySiteResponse:
"""Auto-classify pending cookies for a site against known patterns."""
await _get_org_site(site_id, current_user, db)
results = await classify_site_cookies(db, site_id, only_pending=True)
matched_count = sum(1 for r in results if r.matched)
return ClassifySiteResponse(
site_id=str(site_id),
total=len(results),
matched=matched_count,
unmatched=len(results) - matched_count,
results=[
ClassificationResultResponse(
cookie_name=r.cookie_name,
cookie_domain=r.cookie_domain,
category_id=r.category_id,
category_slug=r.category_slug,
vendor=r.vendor,
description=r.description,
match_source=r.match_source,
matched=r.matched,
)
for r in results
],
)
@router.post(
"/sites/{site_id}/classify/preview",
response_model=ClassificationResultResponse,
)
async def classify_preview(
site_id: uuid.UUID,
body: ClassifySingleRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ClassificationResultResponse:
"""Preview classification for a single cookie without saving."""
await _get_org_site(site_id, current_user, db)
result = await classify_single_cookie(db, site_id, body.cookie_name, body.cookie_domain)
return ClassificationResultResponse(
cookie_name=result.cookie_name,
cookie_domain=result.cookie_domain,
category_id=result.category_id,
category_slug=result.category_slug,
vendor=result.vendor,
description=result.description,
match_source=result.match_source,
matched=result.matched,
)

View File

@@ -0,0 +1,69 @@
"""Organisation-level default configuration endpoints.
Provides GET and PUT for the organisation's global config defaults.
These defaults sit between system defaults and site config in the cascade.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.models.org_config import OrgConfig
from src.schemas.auth import CurrentUser
from src.schemas.org_config import OrgConfigResponse, OrgConfigUpdate
from src.services.dependencies import require_role
router = APIRouter(prefix="/org-config", tags=["organisations"])
@router.get("/", response_model=OrgConfigResponse)
async def get_org_config(
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> OrgConfig:
"""Retrieve the organisation's global configuration defaults."""
result = await db.execute(
select(OrgConfig).where(OrgConfig.organisation_id == current_user.organisation_id)
)
config = result.scalar_one_or_none()
if config is None:
# Auto-create an empty config row so the response is always valid
config = OrgConfig(organisation_id=current_user.organisation_id)
db.add(config)
await db.flush()
await db.refresh(config)
return config
@router.put("/", response_model=OrgConfigResponse)
async def update_org_config(
body: OrgConfigUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> OrgConfig:
"""Create or update the organisation's global configuration defaults.
Only non-None fields will override system defaults when resolving site config.
"""
result = await db.execute(
select(OrgConfig).where(OrgConfig.organisation_id == current_user.organisation_id)
)
config = result.scalar_one_or_none()
if config is None:
config = OrgConfig(
organisation_id=current_user.organisation_id,
**body.model_dump(exclude_unset=True),
)
db.add(config)
else:
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(config, field, value)
await db.flush()
await db.refresh(config)
return config

View File

@@ -0,0 +1,118 @@
import hmac
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.config.settings import get_settings
from src.db import get_db
from src.models.organisation import Organisation
from src.schemas.auth import CurrentUser
from src.schemas.organisation import (
OrganisationCreate,
OrganisationResponse,
OrganisationUpdate,
)
from src.services.dependencies import require_role
router = APIRouter(prefix="/organisations", tags=["organisations"])
def _require_bootstrap_token(
x_admin_bootstrap_token: str | None = Header(default=None),
) -> None:
"""Gate organisation creation behind a static bootstrap token.
The token is configured via ``ADMIN_BOOTSTRAP_TOKEN``. When unset
(the default), the endpoint is disabled entirely — operators must
explicitly opt in and should rotate or unset the value after their
initial org is provisioned.
"""
expected = get_settings().admin_bootstrap_token
if not expected:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Organisation creation is disabled. Set ADMIN_BOOTSTRAP_TOKEN "
"in the environment to enable it."
),
)
if not x_admin_bootstrap_token or not hmac.compare_digest(
x_admin_bootstrap_token,
expected,
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing admin bootstrap token",
)
@router.post("/", response_model=OrganisationResponse, status_code=status.HTTP_201_CREATED)
async def create_organisation(
body: OrganisationCreate,
db: AsyncSession = Depends(get_db),
_: None = Depends(_require_bootstrap_token),
) -> Organisation:
"""Create a new organisation. Gated by ``X-Admin-Bootstrap-Token``.
See :func:`_require_bootstrap_token` for the gating semantics. Once
your initial organisation exists, rotate or unset
``ADMIN_BOOTSTRAP_TOKEN`` to disable further tenant creation.
"""
# Check slug uniqueness
existing = await db.execute(select(Organisation).where(Organisation.slug == body.slug))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Organisation with slug '{body.slug}' already exists",
)
org = Organisation(**body.model_dump())
db.add(org)
await db.flush()
await db.refresh(org)
return org
@router.get("/me", response_model=OrganisationResponse)
async def get_my_organisation(
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> Organisation:
"""Get the current user's organisation."""
result = await db.execute(
select(Organisation).where(
Organisation.id == current_user.organisation_id,
Organisation.deleted_at.is_(None),
)
)
org = result.scalar_one_or_none()
if org is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation not found")
return org
@router.patch("/me", response_model=OrganisationResponse)
async def update_my_organisation(
body: OrganisationUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> Organisation:
"""Update the current user's organisation. Requires owner or admin role."""
result = await db.execute(
select(Organisation).where(
Organisation.id == current_user.organisation_id,
Organisation.deleted_at.is_(None),
)
)
org = result.scalar_one_or_none()
if org is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation not found")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(org, field, value)
await db.flush()
await db.refresh(org)
return org

View File

@@ -0,0 +1,310 @@
"""Scanner and client-side cookie report endpoints.
Accepts cookie reports from the client-side reporter embedded in the banner
bundle, upserts discovered cookies into the site's cookie inventory, and
provides scan job management (trigger, list, detail, diff).
"""
import logging
import uuid
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.models.cookie import Cookie
from src.models.scan import ScanJob, ScanResult
from src.models.site import Site
from src.schemas.auth import CurrentUser
from src.schemas.scanner import (
CookieReportRequest,
CookieReportResponse,
ScanDiffResponse,
ScanJobDetailResponse,
ScanJobResponse,
TriggerScanRequest,
)
from src.services.dependencies import get_current_user
from src.services.scanner import (
compute_scan_diff,
create_scan_job,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scanner", tags=["scanner"])
# ── Client-side cookie report (public, no auth) ─────────────────────
@router.post(
"/report",
response_model=CookieReportResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def receive_cookie_report(
body: CookieReportRequest,
db: AsyncSession = Depends(get_db),
) -> CookieReportResponse:
"""Receive a cookie report from the client-side reporter.
This is a public endpoint (no auth) since it's called from the banner
script running on end-user browsers. The site_id acts as implicit auth.
"""
# Verify site exists
site_result = await db.execute(
select(Site).where(
Site.id == body.site_id,
Site.deleted_at.is_(None),
)
)
if site_result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site not found",
)
new_cookies = 0
now_iso = datetime.now(UTC).isoformat()
for reported in body.cookies:
# Check if this cookie already exists for the site
existing = await db.execute(
select(Cookie).where(
Cookie.site_id == body.site_id,
Cookie.name == reported.name,
Cookie.domain == reported.domain,
Cookie.storage_type == reported.storage_type,
)
)
cookie = existing.scalar_one_or_none()
if cookie:
# Update last_seen_at timestamp
cookie.last_seen_at = now_iso
else:
# Create new cookie record
cookie = Cookie(
site_id=body.site_id,
name=reported.name,
domain=reported.domain,
storage_type=reported.storage_type,
path=reported.path,
is_secure=reported.is_secure,
same_site=reported.same_site,
review_status="pending",
first_seen_at=now_iso,
last_seen_at=now_iso,
)
db.add(cookie)
new_cookies += 1
await db.flush()
return CookieReportResponse(
accepted=True,
cookies_received=len(body.cookies),
new_cookies=new_cookies,
)
# ── Scan job management (authenticated) ─────────────────────────────
async def _verify_site_access(
site_id: uuid.UUID,
user: CurrentUser,
db: AsyncSession,
) -> Site:
"""Verify site exists and belongs to the user's organisation."""
result = await db.execute(
select(Site).where(
Site.id == site_id,
Site.organisation_id == user.organisation_id,
Site.deleted_at.is_(None),
)
)
site = result.scalar_one_or_none()
if site is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site not found",
)
return site
@router.post(
"/scans",
response_model=ScanJobResponse,
status_code=status.HTTP_201_CREATED,
)
async def trigger_scan(
body: TriggerScanRequest,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> ScanJob:
"""Trigger a new cookie scan for a site.
Creates a scan job in 'pending' state and dispatches it to the
Celery worker queue for execution.
"""
from src.services.scanner import complete_scan_job
await _verify_site_access(body.site_id, user, db)
# Check for an already-running scan
active_result = await db.execute(
select(ScanJob).where(
ScanJob.site_id == body.site_id,
ScanJob.status.in_(["pending", "running"]),
)
)
active_jobs = list(active_result.scalars().all())
now = datetime.now(UTC)
stale_pending_cutoff = now - timedelta(minutes=5)
stale_running_cutoff = now - timedelta(minutes=10)
for active_job in active_jobs:
is_stale_pending = (
active_job.status == "pending"
and active_job.created_at.replace(tzinfo=UTC) < stale_pending_cutoff
)
is_stale_running = (
active_job.status == "running"
and active_job.started_at
and active_job.started_at.replace(tzinfo=UTC) < stale_running_cutoff
)
if is_stale_pending or is_stale_running:
logger.warning(
"Failing stale %s scan job %s for site %s",
active_job.status,
active_job.id,
body.site_id,
)
await complete_scan_job(
db,
active_job,
error_message=(
f"Job was stale ({active_job.status} too long), superseded by new scan"
),
)
else:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A scan is already in progress for this site",
)
job = await create_scan_job(
db,
site_id=body.site_id,
trigger="manual",
max_pages=body.max_pages,
)
# Commit before dispatching to Celery so the worker can find the
# job in the database immediately (avoids race condition).
await db.commit()
# Dispatch to Celery (import here to avoid import at module level
# when Celery broker is unavailable during testing)
try:
from src.tasks.scanner import run_scan
run_scan.delay(str(job.id), str(body.site_id))
except Exception:
logger.exception("Failed to dispatch scan job %s to Celery", job.id)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Background task queue is unavailable — scan job"
" created but cannot be processed. Please try again later."
),
) from None
return job
@router.get("/scans/site/{site_id}", response_model=list[ScanJobResponse])
async def list_scans(
site_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> list[ScanJob]:
"""List scan jobs for a site, most recent first."""
await _verify_site_access(site_id, user, db)
result = await db.execute(
select(ScanJob)
.where(ScanJob.site_id == site_id)
.order_by(ScanJob.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
@router.get("/scans/{scan_id}", response_model=ScanJobDetailResponse)
async def get_scan(
scan_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> dict:
"""Retrieve a scan job with its results."""
result = await db.execute(select(ScanJob).where(ScanJob.id == scan_id))
job = result.scalar_one_or_none()
if job is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scan job not found",
)
# Verify org access
await _verify_site_access(job.site_id, user, db)
# Load results
results = await db.execute(
select(ScanResult).where(ScanResult.scan_job_id == scan_id).order_by(ScanResult.cookie_name)
)
scan_results = list(results.scalars().all())
return {
"id": job.id,
"site_id": job.site_id,
"status": job.status,
"trigger": job.trigger,
"pages_scanned": job.pages_scanned,
"pages_total": job.pages_total,
"cookies_found": job.cookies_found,
"error_message": job.error_message,
"started_at": job.started_at,
"completed_at": job.completed_at,
"created_at": job.created_at,
"updated_at": job.updated_at,
"results": scan_results,
}
@router.get("/scans/{scan_id}/diff", response_model=ScanDiffResponse)
async def get_scan_diff(
scan_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> ScanDiffResponse:
"""Get the diff between a scan and its predecessor."""
result = await db.execute(select(ScanJob).where(ScanJob.id == scan_id))
job = result.scalar_one_or_none()
if job is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scan job not found",
)
await _verify_site_access(job.site_id, user, db)
return await compute_scan_diff(db, current_scan_id=scan_id, site_id=job.site_id)

View File

@@ -0,0 +1,101 @@
"""Site-group-level default configuration endpoints.
Provides GET and PUT for a site group's config defaults.
These defaults sit between org defaults and site config in the cascade.
"""
import uuid
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.site_group import SiteGroup
from src.models.site_group_config import SiteGroupConfig
from src.schemas.auth import CurrentUser
from src.schemas.site_group_config import SiteGroupConfigResponse, SiteGroupConfigUpdate
from src.services.dependencies import require_role
router = APIRouter(prefix="/site-groups", tags=["site-groups"])
@router.get("/{group_id}/config", response_model=SiteGroupConfigResponse)
async def get_site_group_config(
group_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> SiteGroupConfig:
"""Retrieve configuration defaults for a site group."""
await _verify_group_ownership(group_id, current_user.organisation_id, db)
result = await db.execute(
select(SiteGroupConfig).where(SiteGroupConfig.site_group_id == group_id)
)
config = result.scalar_one_or_none()
if config is None:
# Auto-create an empty config row so the response is always valid
config = SiteGroupConfig(site_group_id=group_id)
db.add(config)
await db.flush()
await db.refresh(config)
return config
@router.put("/{group_id}/config", response_model=SiteGroupConfigResponse)
async def update_site_group_config(
group_id: uuid.UUID,
body: SiteGroupConfigUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> SiteGroupConfig:
"""Create or update configuration defaults for a site group.
Only non-None fields will override org/system defaults when resolving site config.
"""
await _verify_group_ownership(group_id, current_user.organisation_id, db)
result = await db.execute(
select(SiteGroupConfig).where(SiteGroupConfig.site_group_id == group_id)
)
config = result.scalar_one_or_none()
if config is None:
config = SiteGroupConfig(
site_group_id=group_id,
**body.model_dump(exclude_unset=True),
)
db.add(config)
else:
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(config, field, value)
await db.flush()
await db.refresh(config)
return config
# -- Helpers ------------------------------------------------------------------
async def _verify_group_ownership(
group_id: uuid.UUID,
organisation_id: uuid.UUID,
db: AsyncSession,
) -> None:
"""Ensure the site group belongs to the user's organisation."""
result = await db.execute(
select(SiteGroup).where(
SiteGroup.id == group_id,
SiteGroup.organisation_id == organisation_id,
SiteGroup.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site group not found",
)

View File

@@ -0,0 +1,198 @@
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.models.site import Site
from src.models.site_group import SiteGroup
from src.schemas.auth import CurrentUser
from src.schemas.site_group import SiteGroupCreate, SiteGroupResponse, SiteGroupUpdate
from src.services.dependencies import require_role
router = APIRouter(prefix="/site-groups", tags=["site-groups"])
@router.post("/", response_model=SiteGroupResponse, status_code=status.HTTP_201_CREATED)
async def create_site_group(
body: SiteGroupCreate,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Create a new site group within the current organisation."""
# Check name uniqueness within the org
existing = await db.execute(
select(SiteGroup).where(
SiteGroup.organisation_id == current_user.organisation_id,
SiteGroup.name == body.name,
SiteGroup.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Site group '{body.name}' already exists in this organisation",
)
group = SiteGroup(
organisation_id=current_user.organisation_id,
name=body.name,
description=body.description,
)
db.add(group)
await db.flush()
await db.refresh(group)
return _to_response(group, site_count=0)
@router.get("/", response_model=list[SiteGroupResponse])
async def list_site_groups(
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> list[dict]:
"""List all site groups in the current organisation with site counts."""
# Subquery for site counts
site_count_sq = (
select(
Site.site_group_id,
func.count(Site.id).label("cnt"),
)
.where(Site.deleted_at.is_(None))
.group_by(Site.site_group_id)
.subquery()
)
result = await db.execute(
select(SiteGroup, func.coalesce(site_count_sq.c.cnt, 0).label("site_count"))
.outerjoin(site_count_sq, SiteGroup.id == site_count_sq.c.site_group_id)
.where(
SiteGroup.organisation_id == current_user.organisation_id,
SiteGroup.deleted_at.is_(None),
)
.order_by(SiteGroup.name)
)
return [_to_response(row.SiteGroup, site_count=row.site_count) for row in result.all()]
@router.get("/{group_id}", response_model=SiteGroupResponse)
async def get_site_group(
group_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Get a specific site group by ID."""
group = await _get_org_group(group_id, current_user.organisation_id, db)
site_count = await _count_sites(group_id, db)
return _to_response(group, site_count=site_count)
@router.patch("/{group_id}", response_model=SiteGroupResponse)
async def update_site_group(
group_id: uuid.UUID,
body: SiteGroupUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Update a site group's name or description."""
group = await _get_org_group(group_id, current_user.organisation_id, db)
update_data = body.model_dump(exclude_unset=True)
# Check name uniqueness if name is being changed
if "name" in update_data and update_data["name"] != group.name:
existing = await db.execute(
select(SiteGroup).where(
SiteGroup.organisation_id == current_user.organisation_id,
SiteGroup.name == update_data["name"],
SiteGroup.deleted_at.is_(None),
SiteGroup.id != group_id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Site group '{update_data['name']}' already exists",
)
for field, value in update_data.items():
setattr(group, field, value)
await db.flush()
await db.refresh(group)
site_count = await _count_sites(group_id, db)
return _to_response(group, site_count=site_count)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_site_group(
group_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> None:
"""Soft-delete a site group. Sites in this group become ungrouped."""
group = await _get_org_group(group_id, current_user.organisation_id, db)
# Ungroup all sites in this group
result = await db.execute(
select(Site).where(
Site.site_group_id == group_id,
Site.deleted_at.is_(None),
)
)
for site in result.scalars().all():
site.site_group_id = None
group.deleted_at = datetime.now(UTC)
await db.flush()
# ── Helpers ──────────────────────────────────────────────────────────
async def _get_org_group(
group_id: uuid.UUID,
organisation_id: uuid.UUID,
db: AsyncSession,
) -> SiteGroup:
"""Fetch a site group ensuring it belongs to the given organisation."""
result = await db.execute(
select(SiteGroup).where(
SiteGroup.id == group_id,
SiteGroup.organisation_id == organisation_id,
SiteGroup.deleted_at.is_(None),
)
)
group = result.scalar_one_or_none()
if group is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site group not found",
)
return group
async def _count_sites(group_id: uuid.UUID, db: AsyncSession) -> int:
"""Count active sites in a group."""
result = await db.execute(
select(func.count(Site.id)).where(
Site.site_group_id == group_id,
Site.deleted_at.is_(None),
)
)
return result.scalar_one()
def _to_response(group: SiteGroup, *, site_count: int) -> dict:
"""Convert a SiteGroup model to a response dict with site_count."""
return {
"id": group.id,
"organisation_id": group.organisation_id,
"name": group.name,
"description": group.description,
"created_at": group.created_at,
"updated_at": group.updated_at,
"site_count": site_count,
}

View File

@@ -0,0 +1,220 @@
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.models.site import Site
from src.models.site_config import SiteConfig
from src.schemas.auth import CurrentUser
from src.schemas.site import (
SiteConfigCreate,
SiteConfigResponse,
SiteConfigUpdate,
SiteCreate,
SiteResponse,
SiteUpdate,
)
from src.services.dependencies import require_role
router = APIRouter(prefix="/sites", tags=["sites"])
# ── Site CRUD ────────────────────────────────────────────────────────
@router.post("/", response_model=SiteResponse, status_code=status.HTTP_201_CREATED)
async def create_site(
body: SiteCreate,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> Site:
"""Create a new site within the current organisation."""
# Check domain uniqueness within the org
existing = await db.execute(
select(Site).where(
Site.organisation_id == current_user.organisation_id,
Site.domain == body.domain,
Site.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Site with domain '{body.domain}' already exists in this organisation",
)
site = Site(
organisation_id=current_user.organisation_id,
domain=body.domain,
display_name=body.display_name,
site_group_id=body.site_group_id,
)
db.add(site)
await db.flush()
# Auto-create a default site configuration
default_config = SiteConfig(site_id=site.id)
db.add(default_config)
await db.flush()
await db.refresh(site)
return site
@router.get("/", response_model=list[SiteResponse])
async def list_sites(
site_group_id: uuid.UUID | None = Query(default=None),
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> list[Site]:
"""List all active sites in the current organisation, optionally filtered by group."""
query = select(Site).where(
Site.organisation_id == current_user.organisation_id,
Site.deleted_at.is_(None),
)
if site_group_id is not None:
query = query.where(Site.site_group_id == site_group_id)
result = await db.execute(query.order_by(Site.domain))
return list(result.scalars().all())
@router.get("/{site_id}", response_model=SiteResponse)
async def get_site(
site_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> Site:
"""Get a specific site by ID."""
site = await _get_org_site(site_id, current_user.organisation_id, db)
return site
@router.patch("/{site_id}", response_model=SiteResponse)
async def update_site(
site_id: uuid.UUID,
body: SiteUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> Site:
"""Update a site's display name or active status."""
site = await _get_org_site(site_id, current_user.organisation_id, db)
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(site, field, value)
await db.flush()
await db.refresh(site)
return site
@router.delete("/{site_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_site(
site_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> None:
"""Soft-delete a site."""
site = await _get_org_site(site_id, current_user.organisation_id, db)
site.deleted_at = datetime.now(UTC)
await db.flush()
# ── Site config CRUD ─────────────────────────────────────────────────
@router.get("/{site_id}/config", response_model=SiteConfigResponse)
async def get_site_config(
site_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> SiteConfig:
"""Get the configuration for a site."""
await _get_org_site(site_id, current_user.organisation_id, db)
result = await db.execute(select(SiteConfig).where(SiteConfig.site_id == site_id))
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site configuration not found. Create one first.",
)
return config
@router.put("/{site_id}/config", response_model=SiteConfigResponse)
async def create_or_replace_site_config(
site_id: uuid.UUID,
body: SiteConfigCreate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> SiteConfig:
"""Create or replace the full configuration for a site."""
await _get_org_site(site_id, current_user.organisation_id, db)
result = await db.execute(select(SiteConfig).where(SiteConfig.site_id == site_id))
existing = result.scalar_one_or_none()
if existing is not None:
for field, value in body.model_dump().items():
setattr(existing, field, value)
await db.flush()
await db.refresh(existing)
return existing
config = SiteConfig(site_id=site_id, **body.model_dump())
db.add(config)
await db.flush()
await db.refresh(config)
return config
@router.patch("/{site_id}/config", response_model=SiteConfigResponse)
async def update_site_config(
site_id: uuid.UUID,
body: SiteConfigUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> SiteConfig:
"""Partially update the configuration for a site."""
await _get_org_site(site_id, current_user.organisation_id, db)
result = await db.execute(select(SiteConfig).where(SiteConfig.site_id == site_id))
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site configuration not found. Create one first.",
)
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(config, field, value)
await db.flush()
await db.refresh(config)
return config
# ── Helpers ──────────────────────────────────────────────────────────
async def _get_org_site(
site_id: uuid.UUID,
organisation_id: uuid.UUID,
db: AsyncSession,
) -> Site:
"""Fetch a site ensuring it belongs to the given organisation."""
result = await db.execute(
select(Site).where(
Site.id == site_id,
Site.organisation_id == organisation_id,
Site.deleted_at.is_(None),
)
)
site = result.scalar_one_or_none()
if site is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
return site

View File

@@ -0,0 +1,195 @@
"""Translation management endpoints.
CRUD for per-site, per-locale translation strings used by the banner script.
"""
import uuid
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.site import Site
from src.models.translation import Translation
from src.schemas.auth import CurrentUser
from src.schemas.translation import TranslationCreate, TranslationResponse, TranslationUpdate
from src.services.dependencies import require_role
router = APIRouter(prefix="/sites/{site_id}/translations", tags=["translations"])
async def _get_org_site(site_id: uuid.UUID, organisation_id: uuid.UUID, db: AsyncSession) -> Site:
"""Ensure site belongs to the current organisation."""
result = await db.execute(
select(Site).where(
Site.id == site_id,
Site.organisation_id == organisation_id,
Site.deleted_at.is_(None),
)
)
site = result.scalar_one_or_none()
if site is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
return site
@router.get("/", response_model=list[TranslationResponse])
async def list_translations(
site_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> list[Translation]:
"""List all translations for a site."""
await _get_org_site(site_id, current_user.organisation_id, db)
result = await db.execute(
select(Translation).where(Translation.site_id == site_id).order_by(Translation.locale)
)
return list(result.scalars().all())
@router.get("/{locale}", response_model=TranslationResponse)
async def get_translation(
site_id: uuid.UUID,
locale: str,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> Translation:
"""Get translation strings for a specific locale."""
await _get_org_site(site_id, current_user.organisation_id, db)
result = await db.execute(
select(Translation).where(
Translation.site_id == site_id,
Translation.locale == locale,
)
)
translation = result.scalar_one_or_none()
if translation is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No translation found for locale '{locale}'",
)
return translation
@router.post("/", response_model=TranslationResponse, status_code=status.HTTP_201_CREATED)
async def create_translation(
site_id: uuid.UUID,
body: TranslationCreate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> Translation:
"""Create a translation for a new locale."""
await _get_org_site(site_id, current_user.organisation_id, db)
# Check for duplicate locale
existing = await db.execute(
select(Translation).where(
Translation.site_id == site_id,
Translation.locale == body.locale,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Translation for locale '{body.locale}' already exists",
)
translation = Translation(
site_id=site_id,
locale=body.locale,
strings=body.strings,
)
db.add(translation)
await db.flush()
await db.refresh(translation)
return translation
@router.put("/{locale}", response_model=TranslationResponse)
async def update_translation(
site_id: uuid.UUID,
locale: str,
body: TranslationUpdate,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
db: AsyncSession = Depends(get_db),
) -> Translation:
"""Replace the strings for an existing locale translation."""
await _get_org_site(site_id, current_user.organisation_id, db)
result = await db.execute(
select(Translation).where(
Translation.site_id == site_id,
Translation.locale == locale,
)
)
translation = result.scalar_one_or_none()
if translation is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No translation found for locale '{locale}'",
)
translation.strings = body.strings
await db.flush()
await db.refresh(translation)
return translation
@router.delete("/{locale}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_translation(
site_id: uuid.UUID,
locale: str,
current_user: CurrentUser = Depends(require_role("owner", "admin")),
db: AsyncSession = Depends(get_db),
) -> None:
"""Delete a translation for a specific locale."""
await _get_org_site(site_id, current_user.organisation_id, db)
result = await db.execute(
select(Translation).where(
Translation.site_id == site_id,
Translation.locale == locale,
)
)
translation = result.scalar_one_or_none()
if translation is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No translation found for locale '{locale}'",
)
await db.delete(translation)
await db.flush()
# ── Public endpoint for the banner script ────────────────────────────
public_router = APIRouter(prefix="/translations", tags=["translations"])
@public_router.get("/{site_id}/{locale}")
async def get_public_translation(
site_id: uuid.UUID,
locale: str,
db: AsyncSession = Depends(get_db),
) -> dict[str, str]:
"""Public endpoint: return translation strings for the banner script.
No auth required. Returns the raw strings dict for a given site and locale.
Returns 404 if no translation exists (banner falls back to English defaults).
"""
result = await db.execute(
select(Translation)
.join(Site)
.where(
Translation.site_id == site_id,
Translation.locale == locale,
Site.is_active.is_(True),
Site.deleted_at.is_(None),
)
)
translation = result.scalar_one_or_none()
if translation is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Translation not found",
)
return translation.strings

View File

@@ -0,0 +1,136 @@
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()