From 341dad643fe18f06f784950cf560d3b80a040cd9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:06:40 +0000
Subject: [PATCH 01/70] Initial plan
From 3afd406c599a295d72c8af895641754ffebbf293 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:13:25 +0000
Subject: [PATCH 02/70] Initial plan
From b4131e0d19dd7ab5b083a5b58a41ace36590f43f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:14:47 +0000
Subject: [PATCH 03/70] Initial plan
From 666f739f4e1b5eb42d0699d0d5a8a20f05f0be46 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 23:55:29 +0000
Subject: [PATCH 04/70] feat(compliance): add GDPR, HIPAA, SOC2 compliance
templates with one-click apply and dashboard
- Add ComplianceTemplate model in app/models.py
- Create database migration 027_add_compliance_templates
- Add compliance_enabled feature flag to config and settings metadata
- Create compliance_service.py with pre-built template definitions and evaluation
- Create compliance API endpoints (list, get, apply, status, summary)
- Create compliance admin view and dashboard template
- Add compliance link to admin navigation (desktop and mobile)
- Seed compliance templates at application startup
- Add comprehensive tests (29 passing)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/__init__.py | 2 +
app/api/compliance.py | 179 +++++++
app/config.py | 8 +
app/main.py | 14 +
app/models.py | 23 +
app/utils/compliance_service.py | 433 +++++++++++++++
app/utils/settings_service.py | 12 +
app/views/__init__.py | 2 +
app/views/compliance.py | 48 ++
frontend/templates/base.html | 6 +
frontend/templates/compliance.html | 300 +++++++++++
.../versions/027_add_compliance_templates.py | 44 ++
tests/conftest.py | 1 +
tests/test_compliance.py | 501 ++++++++++++++++++
14 files changed, 1573 insertions(+)
create mode 100644 app/api/compliance.py
create mode 100644 app/utils/compliance_service.py
create mode 100644 app/views/compliance.py
create mode 100644 frontend/templates/compliance.html
create mode 100644 migrations/versions/027_add_compliance_templates.py
create mode 100644 tests/test_compliance.py
diff --git a/app/api/__init__.py b/app/api/__init__.py
index ae98cbd7..b221aa87 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -11,6 +11,7 @@ from app.api.api_tokens import router as api_tokens_router
from app.api.azure import router as azure_router
from app.api.backup import router as backup_router
from app.api.billing import router as billing_router
+from app.api.compliance import router as compliance_router
from app.api.database import router as database_router
from app.api.diagnostic import router as diagnostic_router
from app.api.dropbox import router as dropbox_router
@@ -82,3 +83,4 @@ router.include_router(imap_accounts_router)
router.include_router(integrations_router)
router.include_router(notifications_router)
router.include_router(scheduled_jobs_router)
+router.include_router(compliance_router)
diff --git a/app/api/compliance.py b/app/api/compliance.py
new file mode 100644
index 00000000..12af7144
--- /dev/null
+++ b/app/api/compliance.py
@@ -0,0 +1,179 @@
+"""API endpoints for managing compliance templates (GDPR, HIPAA, SOC2).
+
+All endpoints require admin privileges.
+
+Available routes:
+ GET /api/compliance/templates – list all compliance templates
+ GET /api/compliance/templates/{name} – get a single template with checks
+ POST /api/compliance/templates/{name}/apply – one-click apply a template
+ GET /api/compliance/templates/{name}/status – evaluate compliance status
+ GET /api/compliance/summary – overall compliance dashboard data
+"""
+
+import logging
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.utils.compliance_service import (
+ apply_template,
+ evaluate_template_status,
+ get_all_templates,
+ get_compliance_summary,
+ get_template_by_name,
+)
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/compliance", tags=["compliance"])
+
+DbSession = Annotated[Session, Depends(get_db)]
+
+
+# ---------------------------------------------------------------------------
+# Authorisation helper
+# ---------------------------------------------------------------------------
+
+
+def _require_admin(request: Request) -> dict:
+ """Ensure the caller is an admin; raises HTTP 403 otherwise."""
+ user = request.session.get("user")
+ if not user or not user.get("is_admin"):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
+ return user
+
+
+AdminUser = Annotated[dict, Depends(_require_admin)]
+
+
+# ---------------------------------------------------------------------------
+# Pydantic response models
+# ---------------------------------------------------------------------------
+
+
+class CheckResult(BaseModel):
+ """Individual compliance check result."""
+
+ key: str
+ label: str
+ description: str
+ expected: str
+ actual: str
+ passing: bool
+
+
+class TemplateStatusResponse(BaseModel):
+ """Status evaluation for a compliance template."""
+
+ status: str
+ total: int
+ passed: int
+ failed: int
+ check_results: list[CheckResult]
+
+
+class TemplateResponse(BaseModel):
+ """Full compliance template representation."""
+
+ id: int
+ name: str
+ display_name: str
+ description: str | None
+ enabled: bool
+ status: str
+ applied_at: str | None
+ applied_by: str | None
+ settings: dict[str, str]
+ checks: list[dict[str, Any]]
+ check_count: int
+
+
+class ApplyResponse(BaseModel):
+ """Result of applying a compliance template."""
+
+ success: bool
+ template: str | None = None
+ applied_settings: dict[str, str] | None = None
+ errors: list[str] | None = None
+ error: str | None = None
+ status: TemplateStatusResponse | None = None
+
+
+class SummaryTemplateResponse(BaseModel):
+ """Per-template summary for the compliance dashboard."""
+
+ name: str
+ display_name: str
+ enabled: bool
+ status: str
+ total: int
+ passed: int
+ failed: int
+ applied_at: str | None
+ applied_by: str | None
+
+
+class ComplianceSummaryResponse(BaseModel):
+ """Overall compliance dashboard summary."""
+
+ overall_status: str
+ total_checks: int
+ total_passed: int
+ total_failed: int
+ templates: list[SummaryTemplateResponse]
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.get("/templates", response_model=list[TemplateResponse])
+async def list_templates(db: DbSession, admin: AdminUser) -> list[dict[str, Any]]:
+ """List all compliance templates with their current status."""
+ return get_all_templates(db)
+
+
+@router.get("/templates/{name}", response_model=TemplateResponse)
+async def get_template(name: str, db: DbSession, admin: AdminUser) -> dict[str, Any]:
+ """Get a single compliance template by name."""
+ templates = get_all_templates(db)
+ for t in templates:
+ if t["name"] == name:
+ return t
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
+
+
+@router.post("/templates/{name}/apply", response_model=ApplyResponse)
+async def apply_compliance_template(name: str, db: DbSession, admin: AdminUser) -> dict[str, Any]:
+ """Apply a compliance template (one-click).
+
+ Writes all template settings to the database and evaluates the resulting
+ compliance status.
+ """
+ template = get_template_by_name(db, name)
+ if template is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
+
+ admin_email = admin.get("email", "admin")
+ result = apply_template(db, name, applied_by=admin_email)
+ if not result.get("success") and result.get("error"):
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=result["error"])
+ return result
+
+
+@router.get("/templates/{name}/status", response_model=TemplateStatusResponse)
+async def get_template_status(name: str, db: DbSession, admin: AdminUser) -> dict[str, Any]:
+ """Evaluate the live compliance status of a template."""
+ template = get_template_by_name(db, name)
+ if template is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
+ return evaluate_template_status(db, name)
+
+
+@router.get("/summary", response_model=ComplianceSummaryResponse)
+async def compliance_summary(db: DbSession, admin: AdminUser) -> dict[str, Any]:
+ """Overall compliance dashboard summary across all templates."""
+ return get_compliance_summary(db)
diff --git a/app/config.py b/app/config.py
index ba6eeb25..8b669186 100644
--- a/app/config.py
+++ b/app/config.py
@@ -484,6 +484,14 @@ class Settings(BaseSettings):
# Feature flags
allow_file_delete: bool = True # Default to allowing file deletion from database
+ compliance_enabled: bool = Field(
+ default=True,
+ description=(
+ "Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). "
+ "When enabled, admins can view compliance status and apply "
+ "pre-built regulatory configurations. Default: True."
+ ),
+ )
# PDF/A archival conversion settings
enable_pdfa_conversion: bool = Field(
diff --git a/app/main.py b/app/main.py
index 228e8ff0..9e4e0e66 100644
--- a/app/main.py
+++ b/app/main.py
@@ -146,6 +146,20 @@ async def lifespan(app: FastAPI):
except Exception:
logging.debug("Scheduled jobs seeding skipped — DB may not be ready yet") # noqa: S110
+ # Seed the built-in compliance templates (GDPR, HIPAA, SOC2) so they
+ # are available in the admin compliance dashboard on first startup.
+ try:
+ from app.database import SessionLocal as _SessionLocal # noqa: F811
+ from app.utils.compliance_service import seed_compliance_templates as _seed_compliance
+
+ _db_compliance = _SessionLocal()
+ try:
+ _seed_compliance(_db_compliance)
+ finally:
+ _db_compliance.close()
+ except Exception:
+ logging.debug("Compliance template seeding skipped — DB may not be ready yet") # noqa: S110
+
# Application is now running
yield
diff --git a/app/models.py b/app/models.py
index 0cc7b53a..b820e5d0 100644
--- a/app/models.py
+++ b/app/models.py
@@ -833,3 +833,26 @@ class ScheduledJob(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+
+class ComplianceTemplate(Base):
+ """Pre-built compliance configuration templates (GDPR, HIPAA, SOC2).
+
+ Each row represents an applied compliance template. The ``settings_json``
+ column stores the concrete setting key/value pairs that were written when
+ the template was applied. ``status`` tracks the current compliance posture.
+ """
+
+ __tablename__ = "compliance_templates"
+
+ id = Column(Integer, primary_key=True, index=True)
+ name = Column(String(50), unique=True, nullable=False, index=True) # GDPR, HIPAA, SOC2
+ display_name = Column(String(100), nullable=False)
+ description = Column(Text, nullable=True)
+ settings_json = Column(Text, nullable=False, default="{}") # JSON of applied settings
+ enabled = Column(Boolean, nullable=False, default=False)
+ status = Column(String(20), nullable=False, default="not_applied") # not_applied, compliant, partial, non_compliant
+ applied_at = Column(DateTime(timezone=True), nullable=True)
+ applied_by = Column(String(255), nullable=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
diff --git a/app/utils/compliance_service.py b/app/utils/compliance_service.py
new file mode 100644
index 00000000..0b52c92c
--- /dev/null
+++ b/app/utils/compliance_service.py
@@ -0,0 +1,433 @@
+"""Compliance service for managing GDPR, HIPAA, and SOC2 compliance templates.
+
+Provides pre-built compliance configurations that can be applied with one click
+to ensure the DocuElevate instance meets regulatory requirements.
+"""
+
+import json
+import logging
+from datetime import datetime, timezone
+from typing import Any
+
+from sqlalchemy.orm import Session
+
+from app.models import ComplianceTemplate
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Pre-built compliance template definitions
+# ---------------------------------------------------------------------------
+
+COMPLIANCE_TEMPLATES: dict[str, dict[str, Any]] = {
+ "gdpr": {
+ "display_name": "GDPR (General Data Protection Regulation)",
+ "description": (
+ "European Union regulation for data protection and privacy. "
+ "Enforces data minimisation, encryption at rest, audit logging, "
+ "and limits PII exposure in telemetry."
+ ),
+ "settings": {
+ "auth_enabled": "True",
+ "sentry_send_default_pii": "False",
+ "security_headers_enabled": "True",
+ "security_header_hsts_enabled": "True",
+ "security_header_csp_enabled": "True",
+ "security_header_x_frame_options_enabled": "True",
+ "enable_deduplication": "True",
+ },
+ "checks": [
+ {
+ "key": "auth_enabled",
+ "expected": "True",
+ "label": "Authentication enabled",
+ "description": "User authentication must be enabled to control access to personal data.",
+ },
+ {
+ "key": "sentry_send_default_pii",
+ "expected": "False",
+ "label": "PII excluded from telemetry",
+ "description": "Personally identifiable information must not be sent to external monitoring services.",
+ },
+ {
+ "key": "security_headers_enabled",
+ "expected": "True",
+ "label": "Security headers enabled",
+ "description": "HTTP security headers protect against common web vulnerabilities.",
+ },
+ {
+ "key": "security_header_hsts_enabled",
+ "expected": "True",
+ "label": "HSTS enabled",
+ "description": "HTTP Strict Transport Security ensures encrypted connections.",
+ },
+ {
+ "key": "security_header_csp_enabled",
+ "expected": "True",
+ "label": "Content Security Policy enabled",
+ "description": "CSP headers prevent cross-site scripting and data injection attacks.",
+ },
+ {
+ "key": "security_header_x_frame_options_enabled",
+ "expected": "True",
+ "label": "Clickjacking protection enabled",
+ "description": "X-Frame-Options header prevents clickjacking attacks.",
+ },
+ {
+ "key": "enable_deduplication",
+ "expected": "True",
+ "label": "Deduplication enabled",
+ "description": "Data minimisation: avoid storing duplicate documents.",
+ },
+ ],
+ },
+ "hipaa": {
+ "display_name": "HIPAA (Health Insurance Portability and Accountability Act)",
+ "description": (
+ "United States regulation for protecting health information. "
+ "Requires strong access controls, audit trails, encryption, "
+ "and strict session management."
+ ),
+ "settings": {
+ "auth_enabled": "True",
+ "multi_user_enabled": "True",
+ "sentry_send_default_pii": "False",
+ "security_headers_enabled": "True",
+ "security_header_hsts_enabled": "True",
+ "security_header_csp_enabled": "True",
+ "security_header_x_frame_options_enabled": "True",
+ "enable_deduplication": "True",
+ },
+ "checks": [
+ {
+ "key": "auth_enabled",
+ "expected": "True",
+ "label": "Authentication enabled",
+ "description": "Access controls are required to protect electronic Protected Health Information (ePHI).",
+ },
+ {
+ "key": "multi_user_enabled",
+ "expected": "True",
+ "label": "Multi-user mode enabled",
+ "description": "Individual user accounts required for access accountability.",
+ },
+ {
+ "key": "sentry_send_default_pii",
+ "expected": "False",
+ "label": "PII excluded from telemetry",
+ "description": "Protected Health Information must not be sent to external services.",
+ },
+ {
+ "key": "security_headers_enabled",
+ "expected": "True",
+ "label": "Security headers enabled",
+ "description": "Security headers protect ePHI during transmission.",
+ },
+ {
+ "key": "security_header_hsts_enabled",
+ "expected": "True",
+ "label": "HSTS enabled",
+ "description": "Encrypted transport required for all ePHI transmissions.",
+ },
+ {
+ "key": "security_header_csp_enabled",
+ "expected": "True",
+ "label": "Content Security Policy enabled",
+ "description": "CSP prevents injection attacks that could expose ePHI.",
+ },
+ {
+ "key": "security_header_x_frame_options_enabled",
+ "expected": "True",
+ "label": "Clickjacking protection enabled",
+ "description": "Prevents embedding the application in unauthorized frames.",
+ },
+ {
+ "key": "enable_deduplication",
+ "expected": "True",
+ "label": "Deduplication enabled",
+ "description": "Minimise data footprint for ePHI.",
+ },
+ ],
+ },
+ "soc2": {
+ "display_name": "SOC 2 (Service Organization Control 2)",
+ "description": (
+ "Trust Service Criteria framework for service organisations. "
+ "Focuses on security, availability, processing integrity, "
+ "confidentiality, and privacy."
+ ),
+ "settings": {
+ "auth_enabled": "True",
+ "multi_user_enabled": "True",
+ "sentry_send_default_pii": "False",
+ "security_headers_enabled": "True",
+ "security_header_hsts_enabled": "True",
+ "security_header_csp_enabled": "True",
+ "security_header_x_frame_options_enabled": "True",
+ "enable_deduplication": "True",
+ },
+ "checks": [
+ {
+ "key": "auth_enabled",
+ "expected": "True",
+ "label": "Authentication enabled",
+ "description": "Logical access controls required (CC6.1).",
+ },
+ {
+ "key": "multi_user_enabled",
+ "expected": "True",
+ "label": "Multi-user mode enabled",
+ "description": "Individual user accounts for access management (CC6.2).",
+ },
+ {
+ "key": "sentry_send_default_pii",
+ "expected": "False",
+ "label": "PII excluded from telemetry",
+ "description": "Confidential information must not leak to external services (CC6.7).",
+ },
+ {
+ "key": "security_headers_enabled",
+ "expected": "True",
+ "label": "Security headers enabled",
+ "description": "Protection against common web threats (CC6.6).",
+ },
+ {
+ "key": "security_header_hsts_enabled",
+ "expected": "True",
+ "label": "HSTS enabled",
+ "description": "Encrypted transport in transit (CC6.7).",
+ },
+ {
+ "key": "security_header_csp_enabled",
+ "expected": "True",
+ "label": "Content Security Policy enabled",
+ "description": "Application-level security controls (CC6.6).",
+ },
+ {
+ "key": "security_header_x_frame_options_enabled",
+ "expected": "True",
+ "label": "Clickjacking protection enabled",
+ "description": "UI redress attack prevention (CC6.6).",
+ },
+ {
+ "key": "enable_deduplication",
+ "expected": "True",
+ "label": "Deduplication enabled",
+ "description": "Data integrity through deduplication (PI1.1).",
+ },
+ ],
+ },
+}
+
+
+def seed_compliance_templates(db: Session) -> None:
+ """Create or update the built-in compliance template rows.
+
+ Called once at application startup to ensure the ``compliance_templates``
+ table always contains the latest definitions.
+ """
+ for name, defn in COMPLIANCE_TEMPLATES.items():
+ existing = db.query(ComplianceTemplate).filter(ComplianceTemplate.name == name).first()
+ if existing is None:
+ template = ComplianceTemplate(
+ name=name,
+ display_name=defn["display_name"],
+ description=defn["description"],
+ settings_json=json.dumps(defn["settings"]),
+ enabled=False,
+ status="not_applied",
+ )
+ db.add(template)
+ logger.info(f"Seeded compliance template: {name}")
+ else:
+ # Update display_name and description if changed, but preserve user state
+ existing.display_name = defn["display_name"]
+ existing.description = defn["description"]
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ logger.exception("Failed to seed compliance templates")
+
+
+def get_all_templates(db: Session) -> list[dict[str, Any]]:
+ """Return all compliance templates with their current status."""
+ templates = db.query(ComplianceTemplate).order_by(ComplianceTemplate.name).all()
+ result = []
+ for t in templates:
+ defn = COMPLIANCE_TEMPLATES.get(t.name, {})
+ checks = defn.get("checks", [])
+ result.append(
+ {
+ "id": t.id,
+ "name": t.name,
+ "display_name": t.display_name,
+ "description": t.description,
+ "enabled": t.enabled,
+ "status": t.status,
+ "applied_at": t.applied_at.isoformat() if t.applied_at else None,
+ "applied_by": t.applied_by,
+ "settings": json.loads(t.settings_json) if t.settings_json else {},
+ "checks": checks,
+ "check_count": len(checks),
+ }
+ )
+ return result
+
+
+def get_template_by_name(db: Session, name: str) -> ComplianceTemplate | None:
+ """Retrieve a single compliance template by name."""
+ return db.query(ComplianceTemplate).filter(ComplianceTemplate.name == name).first()
+
+
+def evaluate_template_status(db: Session, name: str) -> dict[str, Any]:
+ """Evaluate the compliance status of a template against live settings.
+
+ Returns a dict with ``status``, ``total``, ``passed``, ``failed``, and
+ a list of individual ``check_results``.
+ """
+ from app.config import settings as app_settings
+ from app.utils.settings_service import get_all_settings_from_db
+
+ defn = COMPLIANCE_TEMPLATES.get(name)
+ if defn is None:
+ return {"status": "unknown", "total": 0, "passed": 0, "failed": 0, "check_results": []}
+
+ db_settings = get_all_settings_from_db(db)
+ checks = defn.get("checks", [])
+ results: list[dict[str, Any]] = []
+ passed = 0
+
+ for check in checks:
+ key = check["key"]
+ expected = check["expected"]
+
+ # Resolve effective value: DB > config object
+ if key in db_settings and db_settings[key] is not None:
+ actual = str(db_settings[key])
+ else:
+ actual = str(getattr(app_settings, key, ""))
+
+ is_passing = actual.lower() == expected.lower()
+ if is_passing:
+ passed += 1
+
+ results.append(
+ {
+ "key": key,
+ "label": check["label"],
+ "description": check["description"],
+ "expected": expected,
+ "actual": actual,
+ "passing": is_passing,
+ }
+ )
+
+ total = len(checks)
+ if passed == total:
+ status = "compliant"
+ elif passed > 0:
+ status = "partial"
+ else:
+ status = "non_compliant"
+
+ return {
+ "status": status,
+ "total": total,
+ "passed": passed,
+ "failed": total - passed,
+ "check_results": results,
+ }
+
+
+def apply_template(db: Session, name: str, applied_by: str = "admin") -> dict[str, Any]:
+ """Apply a compliance template by writing its settings to the database.
+
+ Returns a summary of what was applied.
+ """
+ from app.utils.settings_service import save_setting_to_db
+
+ defn = COMPLIANCE_TEMPLATES.get(name)
+ if defn is None:
+ return {"success": False, "error": f"Unknown template: {name}"}
+
+ template = get_template_by_name(db, name)
+ if template is None:
+ return {"success": False, "error": f"Template not found in database: {name}"}
+
+ applied_settings: dict[str, str] = {}
+ errors: list[str] = []
+
+ for key, value in defn["settings"].items():
+ try:
+ save_setting_to_db(db, key, value, changed_by=f"compliance:{name}")
+ applied_settings[key] = value
+ except Exception as e:
+ errors.append(f"{key}: {e}")
+ logger.error(f"Failed to apply compliance setting {key}={value}: {e}")
+
+ # Update the template record
+ now = datetime.now(timezone.utc)
+ template.enabled = True
+ template.settings_json = json.dumps(applied_settings)
+ template.applied_at = now
+ template.applied_by = applied_by
+
+ # Evaluate and store status
+ eval_result = evaluate_template_status(db, name)
+ template.status = eval_result["status"]
+
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ logger.exception(f"Failed to update compliance template record: {name}")
+ return {"success": False, "error": "Database commit failed"}
+
+ logger.info(f"Applied compliance template '{name}' by {applied_by}: {len(applied_settings)} settings written")
+
+ return {
+ "success": len(errors) == 0,
+ "template": name,
+ "applied_settings": applied_settings,
+ "errors": errors,
+ "status": eval_result,
+ }
+
+
+def get_compliance_summary(db: Session) -> dict[str, Any]:
+ """Return a high-level compliance dashboard summary across all templates."""
+ templates = db.query(ComplianceTemplate).order_by(ComplianceTemplate.name).all()
+ summary: list[dict[str, Any]] = []
+ total_checks = 0
+ total_passed = 0
+
+ for t in templates:
+ eval_result = evaluate_template_status(db, t.name)
+ total_checks += eval_result["total"]
+ total_passed += eval_result["passed"]
+ summary.append(
+ {
+ "name": t.name,
+ "display_name": t.display_name,
+ "enabled": t.enabled,
+ "status": eval_result["status"],
+ "total": eval_result["total"],
+ "passed": eval_result["passed"],
+ "failed": eval_result["failed"],
+ "applied_at": t.applied_at.isoformat() if t.applied_at else None,
+ "applied_by": t.applied_by,
+ }
+ )
+
+ overall = "compliant" if total_checks > 0 and total_passed == total_checks else "non_compliant"
+ if 0 < total_passed < total_checks:
+ overall = "partial"
+
+ return {
+ "overall_status": overall,
+ "total_checks": total_checks,
+ "total_passed": total_passed,
+ "total_failed": total_checks - total_passed,
+ "templates": summary,
+ }
diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py
index 7516f8f9..48d06012 100644
--- a/app/utils/settings_service.py
+++ b/app/utils/settings_service.py
@@ -1595,6 +1595,18 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
+ "compliance_enabled": {
+ "category": "Feature Flags",
+ "description": (
+ "Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). "
+ "When enabled, admins can view compliance status and apply "
+ "pre-built regulatory configurations. Default: True."
+ ),
+ "type": "boolean",
+ "sensitive": False,
+ "required": False,
+ "restart_required": False,
+ },
# Backup / Restore
"backup_enabled": {
"category": "Backup",
diff --git a/app/views/__init__.py b/app/views/__init__.py
index 5c098527..0dd0b3ee 100644
--- a/app/views/__init__.py
+++ b/app/views/__init__.py
@@ -7,6 +7,7 @@ from fastapi import APIRouter
from app.views.admin_users import router as admin_users_router
from app.views.api_tokens import router as api_tokens_router
from app.views.backup import router as backup_router
+from app.views.compliance import router as compliance_router
from app.views.db_wizard import router as db_wizard_router
from app.views.dropbox import router as dropbox_router
from app.views.filemanager import router as filemanager_router
@@ -61,3 +62,4 @@ router.include_router(integrations_router) # Unified integrations dashboard
router.include_router(notifications_router) # User notification dashboard
router.include_router(scheduled_jobs_router) # Admin scheduled batch jobs
router.include_router(help_router) # Built-in help / How-To docs
+router.include_router(compliance_router) # Compliance templates dashboard
diff --git a/app/views/compliance.py b/app/views/compliance.py
new file mode 100644
index 00000000..e9888f97
--- /dev/null
+++ b/app/views/compliance.py
@@ -0,0 +1,48 @@
+"""Admin view: compliance templates dashboard page."""
+
+import logging
+
+from fastapi import HTTPException, Request, status
+from fastapi.responses import RedirectResponse
+
+from app.views.base import APIRouter, require_login, settings, templates
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+def _require_admin(request: Request):
+ """Return the session user if they are an admin, else None."""
+ user = request.session.get("user")
+ if not user or not user.get("is_admin"):
+ logger.warning("Non-admin user attempted to access /admin/compliance")
+ return None
+ return user
+
+
+@router.get("/admin/compliance")
+@require_login
+async def compliance_page(request: Request):
+ """Admin compliance templates dashboard page.
+
+ Displays GDPR, HIPAA, and SOC2 compliance templates with their current
+ status and one-click apply functionality.
+ """
+ user = _require_admin(request)
+ if user is None:
+ return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
+
+ try:
+ return templates.TemplateResponse(
+ "compliance.html",
+ {
+ "request": request,
+ "app_version": settings.version,
+ },
+ )
+ except Exception as e:
+ logger.error(f"Error loading compliance page: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to load compliance page",
+ )
diff --git a/frontend/templates/base.html b/frontend/templates/base.html
index e4bb200a..6d078574 100644
--- a/frontend/templates/base.html
+++ b/frontend/templates/base.html
@@ -168,6 +168,9 @@
Scheduled Jobs
+
+ Compliance
+
Backup & Restore
@@ -338,6 +341,9 @@
Scheduled Jobs
+
+ Compliance
+
Backup & Restore
diff --git a/frontend/templates/compliance.html b/frontend/templates/compliance.html
new file mode 100644
index 00000000..aa41ab6e
--- /dev/null
+++ b/frontend/templates/compliance.html
@@ -0,0 +1,300 @@
+{% extends "base.html" %}
+{% block title %}Compliance Templates - DocuElevate{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
Compliance Templates
+
+ Pre-built compliance configurations for GDPR, HIPAA, and SOC 2.
+ Apply templates with one click to align your instance with regulatory requirements.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ of checks passing across all templates
+
+
+
+
+
+
+
Passed
+
+
+
+
Failed
+
+
+
+
Total
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Compliance Checks (/)
+
+
+
+
+
+
+
+
+
+
+
+ Current:
+ →
+ Expected:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Last applied:
+ by
+
+
+
+
+
+
+
+
+
No compliance templates available
+
Templates will appear here after seeding.
+
+
+
+
+
+
Loading compliance templates…
+
+
+
+
+{% endblock %}
diff --git a/migrations/versions/027_add_compliance_templates.py b/migrations/versions/027_add_compliance_templates.py
new file mode 100644
index 00000000..559b701c
--- /dev/null
+++ b/migrations/versions/027_add_compliance_templates.py
@@ -0,0 +1,44 @@
+"""Add compliance_templates table for GDPR, HIPAA, SOC2 compliance templates.
+
+Revision ID: 027_add_compliance_templates
+Revises: 026_add_scheduled_jobs
+Create Date: 2026-03-09
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "027_add_compliance_templates"
+down_revision: Union[str, None] = "026_add_scheduled_jobs"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Create compliance_templates table."""
+ op.create_table(
+ "compliance_templates",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(50), nullable=False),
+ sa.Column("display_name", sa.String(100), nullable=False),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column("settings_json", sa.Text(), nullable=False, server_default="{}"),
+ sa.Column("enabled", sa.Boolean(), nullable=False, server_default="0"),
+ sa.Column("status", sa.String(20), nullable=False, server_default="not_applied"),
+ sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("applied_by", sa.String(255), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name", name="uq_compliance_templates_name"),
+ )
+ op.create_index("ix_compliance_templates_id", "compliance_templates", ["id"])
+ op.create_index("ix_compliance_templates_name", "compliance_templates", ["name"])
+
+
+def downgrade() -> None:
+ """Drop compliance_templates table."""
+ op.drop_index("ix_compliance_templates_name", "compliance_templates")
+ op.drop_index("ix_compliance_templates_id", "compliance_templates")
+ op.drop_table("compliance_templates")
diff --git a/tests/conftest.py b/tests/conftest.py
index ae6db91e..425fdf3a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -61,6 +61,7 @@ from app.main import app as fastapi_app # noqa: E402
# Import models to register them with SQLAlchemy Base
from app.models import ( # noqa: F401, E402
ApiToken,
+ ComplianceTemplate,
DocumentMetadata,
FileRecord,
Pipeline,
diff --git a/tests/test_compliance.py b/tests/test_compliance.py
new file mode 100644
index 00000000..e8b42e62
--- /dev/null
+++ b/tests/test_compliance.py
@@ -0,0 +1,501 @@
+"""
+Tests for the compliance templates feature.
+
+Covers:
+- app/models.py – ComplianceTemplate model
+- app/utils/compliance_service.py – service functions (seed, evaluate, apply)
+- app/api/compliance.py – REST API endpoints
+- app/views/compliance.py – admin view route
+"""
+
+from unittest.mock import Mock, patch
+
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from app.database import Base, get_db
+from app.models import ComplianceTemplate
+from app.utils.compliance_service import (
+ COMPLIANCE_TEMPLATES,
+ apply_template,
+ evaluate_template_status,
+ get_all_templates,
+ get_compliance_summary,
+ get_template_by_name,
+ seed_compliance_templates,
+)
+
+# ---------------------------------------------------------------------------
+# Shared fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def ct_engine():
+ """In-memory SQLite engine for compliance template tests."""
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(bind=engine)
+ yield engine
+ Base.metadata.drop_all(bind=engine)
+
+
+@pytest.fixture()
+def ct_session(ct_engine):
+ """DB session scoped to one test."""
+ Session = sessionmaker(bind=ct_engine)
+ session = Session()
+ yield session
+ session.close()
+
+
+@pytest.fixture()
+def ct_client(ct_engine):
+ """TestClient with in-memory DB and admin override."""
+ from app.api.compliance import _require_admin
+ from app.main import app
+
+ def override_db():
+ Session = sessionmaker(bind=ct_engine)
+ session = Session()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ def override_admin():
+ return {"email": "admin@test.com", "is_admin": True}
+
+ app.dependency_overrides[get_db] = override_db
+ app.dependency_overrides[_require_admin] = override_admin
+
+ with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
+ yield client
+
+ app.dependency_overrides.clear()
+
+
+@pytest.fixture()
+def seeded_session(ct_session):
+ """Session with compliance templates already seeded."""
+ seed_compliance_templates(ct_session)
+ return ct_session
+
+
+@pytest.fixture()
+def seeded_client(ct_engine):
+ """TestClient with seeded compliance templates."""
+ from app.api.compliance import _require_admin
+ from app.main import app
+
+ Session = sessionmaker(bind=ct_engine)
+ session = Session()
+ seed_compliance_templates(session)
+ session.close()
+
+ def override_db():
+ session = Session()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ def override_admin():
+ return {"email": "admin@test.com", "is_admin": True}
+
+ app.dependency_overrides[get_db] = override_db
+ app.dependency_overrides[_require_admin] = override_admin
+
+ with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
+ yield client
+
+ app.dependency_overrides.clear()
+
+
+# ---------------------------------------------------------------------------
+# Model tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestComplianceTemplateModel:
+ """Tests for the ComplianceTemplate database model."""
+
+ def test_create_template(self, ct_session):
+ """Test creating a compliance template."""
+ template = ComplianceTemplate(
+ name="test_template",
+ display_name="Test Template",
+ description="A test compliance template",
+ settings_json='{"auth_enabled": "True"}',
+ enabled=False,
+ status="not_applied",
+ )
+ ct_session.add(template)
+ ct_session.commit()
+
+ assert template.id is not None
+ assert template.name == "test_template"
+ assert template.display_name == "Test Template"
+ assert template.enabled is False
+ assert template.status == "not_applied"
+
+ def test_unique_name_constraint(self, ct_session):
+ """Test that template names must be unique."""
+ t1 = ComplianceTemplate(
+ name="unique_test",
+ display_name="First",
+ settings_json="{}",
+ )
+ ct_session.add(t1)
+ ct_session.commit()
+
+ t2 = ComplianceTemplate(
+ name="unique_test",
+ display_name="Second",
+ settings_json="{}",
+ )
+ ct_session.add(t2)
+ with pytest.raises(Exception):
+ ct_session.commit()
+ ct_session.rollback()
+
+ def test_default_values(self, ct_session):
+ """Test default column values."""
+ template = ComplianceTemplate(
+ name="defaults_test",
+ display_name="Defaults",
+ settings_json="{}",
+ )
+ ct_session.add(template)
+ ct_session.commit()
+
+ assert template.enabled is False
+ assert template.status == "not_applied"
+ assert template.applied_at is None
+ assert template.applied_by is None
+
+
+# ---------------------------------------------------------------------------
+# Service tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestComplianceService:
+ """Tests for compliance_service utility functions."""
+
+ def test_seed_creates_templates(self, ct_session):
+ """Test that seeding creates all three compliance templates."""
+ seed_compliance_templates(ct_session)
+ templates = ct_session.query(ComplianceTemplate).all()
+ names = {t.name for t in templates}
+
+ assert "gdpr" in names
+ assert "hipaa" in names
+ assert "soc2" in names
+ assert len(templates) == 3
+
+ def test_seed_is_idempotent(self, ct_session):
+ """Test that seeding twice does not create duplicates."""
+ seed_compliance_templates(ct_session)
+ seed_compliance_templates(ct_session)
+ templates = ct_session.query(ComplianceTemplate).all()
+ assert len(templates) == 3
+
+ def test_seed_updates_display_name(self, ct_session):
+ """Test that re-seeding updates display_name but preserves state."""
+ seed_compliance_templates(ct_session)
+ gdpr = ct_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
+ gdpr.enabled = True
+ ct_session.commit()
+
+ seed_compliance_templates(ct_session)
+ gdpr = ct_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
+ assert gdpr.enabled is True # User state preserved
+
+ def test_get_all_templates(self, seeded_session):
+ """Test getting all templates."""
+ result = get_all_templates(seeded_session)
+ assert len(result) == 3
+ for t in result:
+ assert "id" in t
+ assert "name" in t
+ assert "display_name" in t
+ assert "checks" in t
+ assert "check_count" in t
+
+ def test_get_template_by_name_exists(self, seeded_session):
+ """Test retrieving an existing template by name."""
+ result = get_template_by_name(seeded_session, "gdpr")
+ assert result is not None
+ assert result.name == "gdpr"
+
+ def test_get_template_by_name_missing(self, seeded_session):
+ """Test retrieving a non-existent template."""
+ result = get_template_by_name(seeded_session, "nonexistent")
+ assert result is None
+
+ @patch("app.utils.settings_service.get_all_settings_from_db")
+ def test_evaluate_template_compliant(self, mock_settings, seeded_session):
+ """Test evaluation when all checks pass."""
+ mock_settings.return_value = {
+ "auth_enabled": "True",
+ "sentry_send_default_pii": "False",
+ "security_headers_enabled": "True",
+ "security_header_hsts_enabled": "True",
+ "security_header_csp_enabled": "True",
+ "security_header_x_frame_options_enabled": "True",
+ "enable_deduplication": "True",
+ }
+
+ result = evaluate_template_status(seeded_session, "gdpr")
+ assert result["status"] == "compliant"
+ assert result["passed"] == result["total"]
+ assert result["failed"] == 0
+
+ @patch("app.utils.settings_service.get_all_settings_from_db")
+ def test_evaluate_template_non_compliant(self, mock_settings, seeded_session):
+ """Test evaluation when no checks pass."""
+ mock_settings.return_value = {}
+
+ with patch("app.config.settings") as mock_app:
+ mock_app.auth_enabled = False
+ mock_app.sentry_send_default_pii = True
+ mock_app.security_headers_enabled = False
+ mock_app.security_header_hsts_enabled = False
+ mock_app.security_header_csp_enabled = False
+ mock_app.security_header_x_frame_options_enabled = False
+ mock_app.enable_deduplication = False
+
+ result = evaluate_template_status(seeded_session, "gdpr")
+ assert result["status"] in ("non_compliant", "partial")
+ assert result["failed"] > 0
+
+ def test_evaluate_unknown_template(self, seeded_session):
+ """Test evaluation of a non-existent template name."""
+ result = evaluate_template_status(seeded_session, "unknown")
+ assert result["status"] == "unknown"
+ assert result["total"] == 0
+
+ @patch("app.utils.settings_service.save_setting_to_db")
+ @patch("app.utils.settings_service.get_all_settings_from_db")
+ def test_apply_template_success(self, mock_get_settings, mock_save, seeded_session):
+ """Test successfully applying a template."""
+ mock_save.return_value = True
+ mock_get_settings.return_value = {
+ "auth_enabled": "True",
+ "sentry_send_default_pii": "False",
+ "security_headers_enabled": "True",
+ "security_header_hsts_enabled": "True",
+ "security_header_csp_enabled": "True",
+ "security_header_x_frame_options_enabled": "True",
+ "enable_deduplication": "True",
+ }
+
+ result = apply_template(seeded_session, "gdpr", applied_by="test@admin.com")
+ assert result["success"] is True
+ assert result["template"] == "gdpr"
+ assert "applied_settings" in result
+
+ # Verify template record updated
+ gdpr = seeded_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
+ assert gdpr.enabled is True
+ assert gdpr.applied_by == "test@admin.com"
+ assert gdpr.applied_at is not None
+
+ def test_apply_unknown_template(self, seeded_session):
+ """Test applying a non-existent template."""
+ result = apply_template(seeded_session, "nonexistent")
+ assert result["success"] is False
+ assert "error" in result
+
+ @patch("app.utils.settings_service.get_all_settings_from_db")
+ def test_get_compliance_summary(self, mock_settings, seeded_session):
+ """Test compliance summary across all templates."""
+ mock_settings.return_value = {}
+
+ result = get_compliance_summary(seeded_session)
+ assert "overall_status" in result
+ assert "total_checks" in result
+ assert "total_passed" in result
+ assert "total_failed" in result
+ assert "templates" in result
+ assert len(result["templates"]) == 3
+
+ def test_compliance_templates_have_checks(self):
+ """Test that all built-in templates have compliance checks."""
+ for name, defn in COMPLIANCE_TEMPLATES.items():
+ assert "checks" in defn, f"Template {name} missing checks"
+ assert len(defn["checks"]) > 0, f"Template {name} has no checks"
+ for check in defn["checks"]:
+ assert "key" in check
+ assert "expected" in check
+ assert "label" in check
+ assert "description" in check
+
+ def test_compliance_templates_have_settings(self):
+ """Test that all built-in templates have settings to apply."""
+ for name, defn in COMPLIANCE_TEMPLATES.items():
+ assert "settings" in defn, f"Template {name} missing settings"
+ assert len(defn["settings"]) > 0, f"Template {name} has no settings"
+
+
+# ---------------------------------------------------------------------------
+# API tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.integration
+class TestComplianceAPI:
+ """Tests for compliance API endpoints."""
+
+ def test_list_templates(self, seeded_client):
+ """Test GET /api/compliance/templates."""
+ resp = seeded_client.get("/api/compliance/templates")
+ assert resp.status_code == status.HTTP_200_OK
+ data = resp.json()
+ assert isinstance(data, list)
+ assert len(data) == 3
+ names = {t["name"] for t in data}
+ assert names == {"gdpr", "hipaa", "soc2"}
+
+ def test_get_single_template(self, seeded_client):
+ """Test GET /api/compliance/templates/gdpr."""
+ resp = seeded_client.get("/api/compliance/templates/gdpr")
+ assert resp.status_code == status.HTTP_200_OK
+ data = resp.json()
+ assert data["name"] == "gdpr"
+ assert "display_name" in data
+ assert "checks" in data
+
+ def test_get_nonexistent_template(self, seeded_client):
+ """Test GET /api/compliance/templates/unknown returns 404."""
+ resp = seeded_client.get("/api/compliance/templates/unknown")
+ assert resp.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_get_template_status(self, seeded_client):
+ """Test GET /api/compliance/templates/gdpr/status."""
+ resp = seeded_client.get("/api/compliance/templates/gdpr/status")
+ assert resp.status_code == status.HTTP_200_OK
+ data = resp.json()
+ assert "status" in data
+ assert "total" in data
+ assert "passed" in data
+ assert "failed" in data
+ assert "check_results" in data
+
+ def test_apply_template(self, seeded_client):
+ """Test POST /api/compliance/templates/gdpr/apply."""
+ resp = seeded_client.post("/api/compliance/templates/gdpr/apply")
+ assert resp.status_code == status.HTTP_200_OK
+ data = resp.json()
+ assert data["success"] is True
+ assert data["template"] == "gdpr"
+ assert "applied_settings" in data
+
+ def test_apply_nonexistent_template(self, seeded_client):
+ """Test POST /api/compliance/templates/unknown/apply returns 404."""
+ resp = seeded_client.post("/api/compliance/templates/unknown/apply")
+ assert resp.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_compliance_summary(self, seeded_client):
+ """Test GET /api/compliance/summary."""
+ resp = seeded_client.get("/api/compliance/summary")
+ assert resp.status_code == status.HTTP_200_OK
+ data = resp.json()
+ assert "overall_status" in data
+ assert "total_checks" in data
+ assert "templates" in data
+ assert len(data["templates"]) == 3
+
+ def test_templates_require_admin(self, ct_engine):
+ """Test that endpoints require admin access."""
+ from app.main import app
+
+ def override_db():
+ Session = sessionmaker(bind=ct_engine)
+ session = Session()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ app.dependency_overrides[get_db] = override_db
+ # Do NOT override _require_admin so it checks session
+
+ with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
+ resp = client.get("/api/compliance/templates")
+ assert resp.status_code == status.HTTP_403_FORBIDDEN
+
+ app.dependency_overrides.clear()
+
+
+# ---------------------------------------------------------------------------
+# View tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestComplianceView:
+ """Tests for the compliance view route."""
+
+ @patch("app.views.compliance.templates")
+ @patch("app.views.compliance.settings")
+ @pytest.mark.asyncio
+ async def test_compliance_page_admin(self, mock_settings, mock_templates):
+ """Test compliance page renders for admin users."""
+ from app.views.compliance import compliance_page
+
+ mock_settings.version = "1.0.0"
+ mock_request = Mock()
+ mock_request.session = {"user": {"id": "admin", "is_admin": True}}
+
+ await compliance_page(mock_request)
+ mock_templates.TemplateResponse.assert_called_once()
+ call_args = mock_templates.TemplateResponse.call_args
+ assert call_args[0][0] == "compliance.html"
+
+ @pytest.mark.asyncio
+ async def test_compliance_page_non_admin_redirects(self):
+ """Test compliance page redirects non-admin users."""
+ from app.views.compliance import compliance_page
+
+ mock_request = Mock()
+ mock_request.session = {"user": {"id": "user1", "is_admin": False}}
+
+ result = await compliance_page(mock_request)
+ assert result.status_code == 302
+
+
+# ---------------------------------------------------------------------------
+# Config / settings metadata tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestComplianceConfig:
+ """Tests for compliance configuration settings."""
+
+ def test_compliance_enabled_default(self):
+ """Test that compliance_enabled defaults to True."""
+ from app.config import settings
+
+ assert hasattr(settings, "compliance_enabled")
+ assert settings.compliance_enabled is True
+
+ def test_compliance_enabled_in_setting_metadata(self):
+ """Test that compliance_enabled has SETTING_METADATA entry."""
+ from app.utils.settings_service import SETTING_METADATA
+
+ assert "compliance_enabled" in SETTING_METADATA
+ meta = SETTING_METADATA["compliance_enabled"]
+ assert meta["category"] == "Feature Flags"
+ assert meta["type"] == "boolean"
From 491c4245806f6566019e826e4b8bb73945477c23 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 10 Mar 2026 00:05:44 +0000
Subject: [PATCH 05/70] docs(compliance): add ComplianceGuide.md and update
ConfigurationGuide with compliance_enabled setting
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
.env.demo | 1 +
docs/ComplianceGuide.md | 190 +++++++++++++++++++++++++++++++++++++
docs/ConfigurationGuide.md | 1 +
3 files changed, 192 insertions(+)
create mode 100644 docs/ComplianceGuide.md
diff --git a/.env.demo b/.env.demo
index 65f82fdf..6c8dc303 100644
--- a/.env.demo
+++ b/.env.demo
@@ -5,6 +5,7 @@ REDIS_URL=redis://redis:6379/0
EXTERNAL_HOSTNAME=docuelevate.example.com
GOTENBERG_URL=http://gotenberg:3000
ALLOW_FILE_DELETE=true # Allow deletion of file records
+COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
# **UI / Appearance**
# Default colour scheme: system (follow OS), light, or dark
diff --git a/docs/ComplianceGuide.md b/docs/ComplianceGuide.md
new file mode 100644
index 00000000..c85150c7
--- /dev/null
+++ b/docs/ComplianceGuide.md
@@ -0,0 +1,190 @@
+# Compliance Templates Guide
+
+DocuElevate includes pre-built compliance templates for **GDPR**, **HIPAA**, and **SOC 2** that help you configure your instance to meet regulatory requirements. This guide covers how to use the compliance dashboard, apply templates, and monitor your compliance status.
+
+## Overview
+
+The compliance templates feature provides:
+
+- **Pre-built configurations** for GDPR, HIPAA, and SOC 2
+- **One-click apply** to configure all required settings at once
+- **Compliance status dashboard** to monitor your regulatory posture
+- **Individual check results** showing which settings are compliant and which need attention
+
+## Accessing the Dashboard
+
+The compliance dashboard is available to **admin users only**.
+
+1. Log in as an administrator
+2. Click **Admin** in the navigation bar
+3. Select **Compliance** from the dropdown menu
+
+Or navigate directly to: `/admin/compliance`
+
+## Available Templates
+
+### GDPR (General Data Protection Regulation)
+
+The European Union regulation for data protection and privacy. The GDPR template enforces:
+
+| Setting | Value | Purpose |
+|---------|-------|---------|
+| `AUTH_ENABLED` | `True` | Controls access to personal data |
+| `SENTRY_SEND_DEFAULT_PII` | `False` | Prevents PII leaking to external services |
+| `SECURITY_HEADERS_ENABLED` | `True` | Protects against common web vulnerabilities |
+| `SECURITY_HEADER_HSTS_ENABLED` | `True` | Ensures encrypted connections |
+| `SECURITY_HEADER_CSP_ENABLED` | `True` | Prevents XSS and injection attacks |
+| `SECURITY_HEADER_X_FRAME_OPTIONS_ENABLED` | `True` | Prevents clickjacking |
+| `ENABLE_DEDUPLICATION` | `True` | Data minimisation — avoids duplicate storage |
+
+### HIPAA (Health Insurance Portability and Accountability Act)
+
+United States regulation for protecting health information. The HIPAA template includes all GDPR settings plus:
+
+| Setting | Value | Purpose |
+|---------|-------|---------|
+| `MULTI_USER_ENABLED` | `True` | Individual accounts for access accountability |
+
+### SOC 2 (Service Organization Control 2)
+
+Trust Service Criteria framework for service organisations. The SOC 2 template includes the same settings as HIPAA, mapped to SOC 2 Trust Service Criteria (CC6.x, PI1.x).
+
+## Applying a Template
+
+1. Navigate to the **Compliance** dashboard (`/admin/compliance`)
+2. Find the template you want to apply (GDPR, HIPAA, or SOC 2)
+3. Click **Apply Template**
+4. Confirm the action in the dialog
+5. The template settings are written to the database immediately
+
+> **Note:** Applying a template writes configuration values to the database. Some settings (e.g., security headers) may require a restart to take effect. Check the Settings page for restart indicators.
+
+## Understanding Compliance Status
+
+Each template shows one of four statuses:
+
+| Status | Badge | Meaning |
+|--------|-------|---------|
+| **Compliant** | Green | All checks are passing |
+| **Partial** | Yellow | Some checks are passing, others are not |
+| **Non-Compliant** | Red | No checks are passing |
+| **Not Applied** | Grey | Template has never been applied |
+
+### Individual Checks
+
+Click **Show Details** on any template card to see individual check results:
+
+- ✅ **Passing** — The setting matches the expected compliance value
+- ❌ **Failing** — The setting does not match; the current and expected values are shown
+
+## API Endpoints
+
+The compliance feature exposes the following API endpoints under `/api/compliance/`:
+
+### List Templates
+
+```bash
+GET /api/compliance/templates
+```
+
+Returns all compliance templates with their current status.
+
+### Get Single Template
+
+```bash
+GET /api/compliance/templates/{name}
+```
+
+Returns a single template by name (`gdpr`, `hipaa`, or `soc2`).
+
+### Apply Template
+
+```bash
+POST /api/compliance/templates/{name}/apply
+```
+
+Applies a compliance template, writing all its settings to the database.
+
+### Get Template Status
+
+```bash
+GET /api/compliance/templates/{name}/status
+```
+
+Evaluates the live compliance status of a template against current settings.
+
+**Response example:**
+
+```json
+{
+ "status": "partial",
+ "total": 7,
+ "passed": 5,
+ "failed": 2,
+ "check_results": [
+ {
+ "key": "auth_enabled",
+ "label": "Authentication enabled",
+ "description": "User authentication must be enabled to control access to personal data.",
+ "expected": "True",
+ "actual": "True",
+ "passing": true
+ }
+ ]
+}
+```
+
+### Compliance Summary
+
+```bash
+GET /api/compliance/summary
+```
+
+Returns an overall compliance summary across all templates.
+
+**Response example:**
+
+```json
+{
+ "overall_status": "partial",
+ "total_checks": 22,
+ "total_passed": 18,
+ "total_failed": 4,
+ "templates": [
+ {
+ "name": "gdpr",
+ "display_name": "GDPR (General Data Protection Regulation)",
+ "enabled": true,
+ "status": "compliant",
+ "total": 7,
+ "passed": 7,
+ "failed": 0,
+ "applied_at": "2026-03-09T12:00:00+00:00",
+ "applied_by": "admin@example.com"
+ }
+ ]
+}
+```
+
+> **Note:** All API endpoints require admin authentication.
+
+## Configuration
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `COMPLIANCE_ENABLED` | `True` | Enable the compliance templates dashboard. Set to `False` to hide the feature. |
+
+## Best Practices
+
+1. **Apply templates before going live** — Set up compliance before processing real documents
+2. **Monitor status regularly** — Check the compliance dashboard after configuration changes
+3. **Use the refresh button** — After changing settings elsewhere, refresh the compliance page to see updated status
+4. **Combine templates** — You can apply multiple templates; settings overlap is handled automatically
+5. **Review after updates** — After upgrading DocuElevate, review your compliance status as new checks may be added
+
+## Related Documentation
+
+- [Configuration Guide](./ConfigurationGuide.md) — Full list of configuration options
+- [Privacy & Compliance Guide](./PrivacyCompliance.md) — Privacy notice and GDPR compliance details
+- [Deployment Guide](./DeploymentGuide.md) — Production deployment with security best practices
+- [Security Audit](../SECURITY_AUDIT.md) — Security findings and mitigations
diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md
index 009dc5a9..fb2bdc8a 100644
--- a/docs/ConfigurationGuide.md
+++ b/docs/ConfigurationGuide.md
@@ -16,6 +16,7 @@ Configuration is primarily done through environment variables specified in a `.e
| `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` |
| `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` |
| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` |
+| `COMPLIANCE_ENABLED` | Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). | `true` |
### Batch Processing Settings
From 542fb46ee7b2dc0c714510cdca69b394f920e5ef Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 10 Mar 2026 00:09:08 +0000
Subject: [PATCH 06/70] fix(compliance): address code review feedback - add
aria-busy, input validation, use IntegrityError
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
app/api/compliance.py | 4 ++++
frontend/templates/compliance.html | 4 +++-
tests/test_compliance.py | 3 ++-
3 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/app/api/compliance.py b/app/api/compliance.py
index 12af7144..811faeaa 100644
--- a/app/api/compliance.py
+++ b/app/api/compliance.py
@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.utils.compliance_service import (
+ COMPLIANCE_TEMPLATES,
apply_template,
evaluate_template_status,
get_all_templates,
@@ -153,6 +154,9 @@ async def apply_compliance_template(name: str, db: DbSession, admin: AdminUser)
Writes all template settings to the database and evaluates the resulting
compliance status.
"""
+ if name not in COMPLIANCE_TEMPLATES:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
+
template = get_template_by_name(db, name)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
diff --git a/frontend/templates/compliance.html b/frontend/templates/compliance.html
index aa41ab6e..06685253 100644
--- a/frontend/templates/compliance.html
+++ b/frontend/templates/compliance.html
@@ -155,9 +155,11 @@
diff --git a/tests/test_compliance.py b/tests/test_compliance.py
index e8b42e62..31ef9030 100644
--- a/tests/test_compliance.py
+++ b/tests/test_compliance.py
@@ -14,6 +14,7 @@ import pytest
from fastapi import status
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
@@ -163,7 +164,7 @@ class TestComplianceTemplateModel:
settings_json="{}",
)
ct_session.add(t2)
- with pytest.raises(Exception):
+ with pytest.raises(IntegrityError):
ct_session.commit()
ct_session.rollback()
From ac6e05278896986ca234e6109e225c599eb982c7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 10 Mar 2026 00:12:15 +0000
Subject: [PATCH 07/70] feat(auth): add social login support for Google,
Microsoft, Apple, and Dropbox
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
.env.demo | 27 ++
app/auth.py | 210 +++++++++++++
app/config.py | 34 +-
app/utils/config_validator/validators.py | 34 +-
app/utils/settings_service.py | 147 +++++++++
docs/AuthenticationSetup.md | 11 +-
docs/ConfigurationGuide.md | 22 ++
docs/SocialLoginSetup.md | 375 +++++++++++++++++++++++
frontend/templates/login.html | 29 +-
9 files changed, 884 insertions(+), 5 deletions(-)
create mode 100644 docs/SocialLoginSetup.md
diff --git a/.env.demo b/.env.demo
index 65f82fdf..8c7a071f 100644
--- a/.env.demo
+++ b/.env.demo
@@ -158,6 +158,33 @@ AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_CONFIG_URL=
OAUTH_PROVIDER_NAME="Authentik SSO"
+# **Social Login Providers**
+# Enable one or more social login providers to let users sign in with existing accounts.
+# Each provider requires separate OAuth credentials. See docs/SocialLoginSetup.md for details.
+
+# Google Sign-In (https://console.cloud.google.com/apis/credentials)
+# SOCIAL_AUTH_GOOGLE_ENABLED=false
+# SOCIAL_AUTH_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
+# SOCIAL_AUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret
+
+# Microsoft Sign-In / Azure AD (https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps)
+# SOCIAL_AUTH_MICROSOFT_ENABLED=false
+# SOCIAL_AUTH_MICROSOFT_CLIENT_ID=your-microsoft-application-id
+# SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret
+# SOCIAL_AUTH_MICROSOFT_TENANT=common # common | organizations | consumers |
+
+# Apple Sign-In (https://developer.apple.com/account/resources)
+# SOCIAL_AUTH_APPLE_ENABLED=false
+# SOCIAL_AUTH_APPLE_CLIENT_ID=com.example.docuelevate
+# SOCIAL_AUTH_APPLE_TEAM_ID=ABCDE12345
+# SOCIAL_AUTH_APPLE_KEY_ID=FGHIJ67890
+# SOCIAL_AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
+
+# Dropbox Sign-In (https://www.dropbox.com/developers/apps)
+# SOCIAL_AUTH_DROPBOX_ENABLED=false
+# SOCIAL_AUTH_DROPBOX_CLIENT_ID=your-dropbox-app-key
+# SOCIAL_AUTH_DROPBOX_CLIENT_SECRET=your-dropbox-app-secret
+
# **AI/ML Services**
# Select your AI provider: openai | azure | anthropic | gemini | ollama | openrouter | portkey | litellm
AI_PROVIDER=openai
diff --git a/app/auth.py b/app/auth.py
index 8811df94..6e3c364e 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -38,6 +38,9 @@ templates = Jinja2Templates(directory=str(templates_dir))
OAUTH_CONFIGURED = False
OAUTH_PROVIDER_NAME = "Single Sign-On"
+# Social login providers that are enabled and registered
+SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
+
if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret:
oauth.register(
name="authentik",
@@ -49,6 +52,68 @@ if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_s
OAUTH_CONFIGURED = True
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
+# --- Social Login Providers ---------------------------------------------------
+if AUTH_ENABLED and settings.social_auth_google_enabled:
+ if settings.social_auth_google_client_id and settings.social_auth_google_client_secret:
+ oauth.register(
+ name="google",
+ client_id=settings.social_auth_google_client_id,
+ client_secret=settings.social_auth_google_client_secret,
+ server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
+ client_kwargs={"scope": "openid profile email"},
+ )
+ SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
+ logger.info("Social login provider registered: Google")
+ else:
+ logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
+
+if AUTH_ENABLED and settings.social_auth_microsoft_enabled:
+ if settings.social_auth_microsoft_client_id and settings.social_auth_microsoft_client_secret:
+ tenant = settings.social_auth_microsoft_tenant or "common"
+ oauth.register(
+ name="microsoft",
+ client_id=settings.social_auth_microsoft_client_id,
+ client_secret=settings.social_auth_microsoft_client_secret,
+ server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
+ client_kwargs={"scope": "openid profile email"},
+ )
+ SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
+ logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
+ else:
+ logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
+
+if AUTH_ENABLED and settings.social_auth_apple_enabled:
+ if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
+ oauth.register(
+ name="apple",
+ client_id=settings.social_auth_apple_client_id,
+ server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
+ client_kwargs={
+ "scope": "openid name email",
+ "response_mode": "form_post",
+ },
+ )
+ SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
+ logger.info("Social login provider registered: Apple")
+ else:
+ logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
+
+if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
+ if settings.social_auth_dropbox_client_id and settings.social_auth_dropbox_client_secret:
+ oauth.register(
+ name="dropbox",
+ client_id=settings.social_auth_dropbox_client_id,
+ client_secret=settings.social_auth_dropbox_client_secret,
+ authorize_url="https://www.dropbox.com/oauth2/authorize",
+ access_token_url="https://api.dropboxapi.com/oauth2/token",
+ userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
+ client_kwargs={"token_endpoint_auth_method": "client_secret_post"},
+ )
+ SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
+ logger.info("Social login provider registered: Dropbox")
+ else:
+ logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
+
router = APIRouter()
@@ -194,6 +259,7 @@ async def login(request: Request):
"message": request.query_params.get("message"),
"show_oauth": OAUTH_CONFIGURED,
"oauth_provider_name": OAUTH_PROVIDER_NAME,
+ "social_providers": SOCIAL_PROVIDERS,
"app_version": settings.version,
"csrf_token": getattr(request.state, "csrf_token", ""),
# "Create account" link is only shown when multi-user mode AND local signup are both enabled
@@ -211,6 +277,148 @@ async def oauth_login(request: Request):
return await oauth.authentik.authorize_redirect(request, redirect_uri)
+async def social_login(request: Request, provider: str):
+ """Initiate a social login flow for the given provider.
+
+ Args:
+ request: The current FastAPI request.
+ provider: One of the registered social provider keys (google, microsoft, apple, dropbox).
+
+ Returns:
+ A redirect to the provider's authorization page, or back to /login on error.
+ """
+ if provider not in SOCIAL_PROVIDERS:
+ return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND)
+
+ redirect_uri = request.url_for("social_callback", provider=provider)
+ oauth_client = getattr(oauth, provider, None)
+ if oauth_client is None:
+ return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND)
+
+ return await oauth_client.authorize_redirect(request, redirect_uri)
+
+
+def _normalize_social_userinfo(provider: str, token: dict, raw_userinfo: dict | None) -> dict:
+ """Normalize the userinfo payload from different social providers into a common format.
+
+ Returns a dict with keys: sub, email, name, preferred_username, picture.
+
+ Args:
+ provider: The social provider key (google, microsoft, apple, dropbox).
+ token: The OAuth token response from the provider.
+ raw_userinfo: The raw userinfo dict (may be None for providers without standard OIDC userinfo).
+
+ Returns:
+ A normalized user-data dict compatible with the session user format.
+ """
+ userinfo: dict = raw_userinfo or {}
+
+ if provider == "dropbox":
+ # Dropbox returns a non-standard userinfo response
+ email = userinfo.get("email", "")
+ name_info = userinfo.get("name", {})
+ display_name = name_info.get("display_name", "") if isinstance(name_info, dict) else str(name_info)
+ return {
+ "sub": userinfo.get("account_id", email),
+ "email": email,
+ "name": display_name,
+ "preferred_username": email,
+ "picture": userinfo.get("profile_photo_url", ""),
+ }
+
+ # Standard OIDC providers (Google, Microsoft, Apple)
+ return {
+ "sub": userinfo.get("sub", ""),
+ "email": userinfo.get("email", ""),
+ "name": userinfo.get("name", ""),
+ "preferred_username": userinfo.get("email", ""),
+ "picture": userinfo.get("picture", ""),
+ }
+
+
+async def social_callback(request: Request, provider: str, db: Session = Depends(get_db)):
+ """Handle the OAuth callback from a social login provider.
+
+ After the user authorizes with the social provider, this endpoint exchanges
+ the authorization code for tokens, extracts user information, creates or
+ updates the user profile, and establishes a session.
+
+ Args:
+ request: The current FastAPI request.
+ provider: One of the registered social provider keys.
+ db: Database session (injected).
+
+ Returns:
+ A redirect to the user's original destination or the upload page.
+ """
+ if provider not in SOCIAL_PROVIDERS:
+ return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND)
+
+ oauth_client = getattr(oauth, provider, None)
+ if oauth_client is None:
+ return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND)
+
+ try:
+ token = await oauth_client.authorize_access_token(request)
+
+ # Try standard OIDC userinfo first, fall back to token-embedded userinfo
+ raw_userinfo = token.get("userinfo")
+ if not raw_userinfo:
+ try:
+ resp = await oauth_client.userinfo(token=token)
+ raw_userinfo = resp if isinstance(resp, dict) else resp.json() if hasattr(resp, "json") else {}
+ except Exception:
+ raw_userinfo = {}
+
+ user_data = _normalize_social_userinfo(provider, token, raw_userinfo)
+
+ if not user_data.get("email"):
+ return RedirectResponse(
+ url="/login?error=Could+not+retrieve+email+from+provider",
+ status_code=status.HTTP_302_FOUND,
+ )
+
+ # Add Gravatar if no picture provided
+ if not user_data.get("picture") and user_data.get("email"):
+ user_data["picture"] = get_gravatar_url(user_data["email"])
+
+ # Tag the login source for audit/debugging
+ user_data["auth_provider"] = provider
+
+ # Social login users are never admin by default (admin must be granted
+ # via the Authentik/OIDC admin group or manually in the admin panel)
+ user_data["is_admin"] = False
+
+ request.session["user"] = user_data
+
+ # Auto-create or update UserProfile
+ _ensure_user_profile(db, user_data, is_admin=False)
+
+ provider_name = SOCIAL_PROVIDERS[provider]["name"]
+ logger.info(
+ "[SECURITY] SOCIAL_LOGIN_SUCCESS provider=%s user=%s", provider_name, user_data.get("email", "unknown")
+ )
+
+ # Redirect first-time users to onboarding
+ user_id = (
+ user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
+ )
+ if user_id:
+ profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
+ if profile and not profile.onboarding_completed:
+ post_onboarding = request.session.pop("redirect_after_login", "/upload")
+ request.session["post_onboarding_redirect"] = post_onboarding
+ return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND)
+
+ redirect_url = request.session.pop("redirect_after_login", "/upload")
+ return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
+ except Exception as e:
+ logger.warning("[SECURITY] SOCIAL_LOGIN_FAILURE provider=%s error=%s", provider, type(e).__name__)
+ return RedirectResponse(
+ url=f"/login?error=Social+login+failed:+{type(e).__name__}", status_code=status.HTTP_302_FOUND
+ )
+
+
def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -> None:
"""Create or update a UserProfile row for *user_data*.
@@ -503,6 +711,8 @@ if AUTH_ENABLED:
router.add_api_route("/login", login, methods=["GET"])
router.add_api_route("/oauth-login", oauth_login, methods=["GET"])
router.add_api_route("/oauth-callback", oauth_callback, methods=["GET"])
+ router.add_api_route("/social-login/{provider}", social_login, methods=["GET"])
+ router.add_api_route("/social-callback/{provider}", social_callback, methods=["GET"])
router.add_api_route("/auth", auth, methods=["POST"])
router.add_api_route("/logout", logout, methods=["GET"])
diff --git a/app/config.py b/app/config.py
index ba6eeb25..ace77689 100644
--- a/app/config.py
+++ b/app/config.py
@@ -166,12 +166,44 @@ class Settings(BaseSettings):
),
)
- # Authentik
+ # Authentik / Generic OIDC
authentik_client_id: Optional[str] = None
authentik_client_secret: Optional[str] = None
authentik_config_url: Optional[str] = None
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider
+ # Social Login Providers
+ # Google OAuth2
+ social_auth_google_enabled: bool = False
+ social_auth_google_client_id: Optional[str] = None
+ social_auth_google_client_secret: Optional[str] = None
+
+ # Microsoft OAuth2 (Azure AD / Microsoft Entra ID)
+ social_auth_microsoft_enabled: bool = False
+ social_auth_microsoft_client_id: Optional[str] = None
+ social_auth_microsoft_client_secret: Optional[str] = None
+ social_auth_microsoft_tenant: str = Field(
+ default="common",
+ description=(
+ "Azure AD tenant ID or one of 'common', 'organizations', 'consumers'. "
+ "Use 'common' to allow any Microsoft account and any Azure AD org. "
+ "Use a specific tenant ID (GUID) to restrict to a single organization. "
+ "Default: common."
+ ),
+ )
+
+ # Apple Sign-In
+ social_auth_apple_enabled: bool = False
+ social_auth_apple_client_id: Optional[str] = None
+ social_auth_apple_team_id: Optional[str] = None
+ social_auth_apple_key_id: Optional[str] = None
+ social_auth_apple_private_key: Optional[str] = None
+
+ # Dropbox OAuth2
+ social_auth_dropbox_enabled: bool = False
+ social_auth_dropbox_client_id: Optional[str] = None
+ social_auth_dropbox_client_secret: Optional[str] = None
+
# Local user signup
allow_local_signup: bool = Field(
default=False,
diff --git a/app/utils/config_validator/validators.py b/app/utils/config_validator/validators.py
index 36fa02e6..6ef8d52b 100644
--- a/app/utils/config_validator/validators.py
+++ b/app/utils/config_validator/validators.py
@@ -59,13 +59,43 @@ def validate_auth_config() -> list[str]:
and getattr(settings, "authentik_config_url", None)
)
- if not using_simple_auth and not using_oidc:
- issues.append("Neither simple authentication nor OIDC are properly configured")
+ # Check if any social login provider is enabled
+ using_social_login = any(
+ getattr(settings, f"social_auth_{p}_enabled", False) for p in ("google", "microsoft", "apple", "dropbox")
+ )
+
+ if not using_simple_auth and not using_oidc and not using_social_login:
+ issues.append("Neither simple authentication, OIDC, nor social login are properly configured")
# If using OIDC, check for provider name
if using_oidc and not getattr(settings, "oauth_provider_name", None):
issues.append("OAUTH_PROVIDER_NAME is not configured but OIDC is enabled")
+ # Validate individual social login provider configs
+ if getattr(settings, "social_auth_google_enabled", False):
+ if not getattr(settings, "social_auth_google_client_id", None):
+ issues.append("SOCIAL_AUTH_GOOGLE_CLIENT_ID is required when Google login is enabled")
+ if not getattr(settings, "social_auth_google_client_secret", None):
+ issues.append("SOCIAL_AUTH_GOOGLE_CLIENT_SECRET is required when Google login is enabled")
+
+ if getattr(settings, "social_auth_microsoft_enabled", False):
+ if not getattr(settings, "social_auth_microsoft_client_id", None):
+ issues.append("SOCIAL_AUTH_MICROSOFT_CLIENT_ID is required when Microsoft login is enabled")
+ if not getattr(settings, "social_auth_microsoft_client_secret", None):
+ issues.append("SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET is required when Microsoft login is enabled")
+
+ if getattr(settings, "social_auth_apple_enabled", False):
+ if not getattr(settings, "social_auth_apple_client_id", None):
+ issues.append("SOCIAL_AUTH_APPLE_CLIENT_ID is required when Apple login is enabled")
+ if not getattr(settings, "social_auth_apple_team_id", None):
+ issues.append("SOCIAL_AUTH_APPLE_TEAM_ID is required when Apple login is enabled")
+
+ if getattr(settings, "social_auth_dropbox_enabled", False):
+ if not getattr(settings, "social_auth_dropbox_client_id", None):
+ issues.append("SOCIAL_AUTH_DROPBOX_CLIENT_ID is required when Dropbox login is enabled")
+ if not getattr(settings, "social_auth_dropbox_client_secret", None):
+ issues.append("SOCIAL_AUTH_DROPBOX_CLIENT_SECRET is required when Dropbox login is enabled")
+
return issues
diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py
index 7516f8f9..2d5e7da4 100644
--- a/app/utils/settings_service.py
+++ b/app/utils/settings_service.py
@@ -182,6 +182,153 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
+ # Social Login Providers
+ "social_auth_google_enabled": {
+ "category": "Social Login",
+ "description": (
+ "Enable Google Sign-In. Requires SOCIAL_AUTH_GOOGLE_CLIENT_ID and "
+ "SOCIAL_AUTH_GOOGLE_CLIENT_SECRET from the Google Cloud Console."
+ ),
+ "type": "boolean",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ "help_link": "https://console.cloud.google.com/apis/credentials",
+ "help_link_label": "Google Cloud Console",
+ },
+ "social_auth_google_client_id": {
+ "category": "Social Login",
+ "description": "Google OAuth2 client ID from the Google Cloud Console.",
+ "type": "string",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_google_client_secret": {
+ "category": "Social Login",
+ "description": "Google OAuth2 client secret from the Google Cloud Console.",
+ "type": "string",
+ "sensitive": True,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_microsoft_enabled": {
+ "category": "Social Login",
+ "description": (
+ "Enable Microsoft Sign-In (Azure AD / Microsoft Entra ID). Requires "
+ "SOCIAL_AUTH_MICROSOFT_CLIENT_ID and SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET "
+ "from Azure App Registrations."
+ ),
+ "type": "boolean",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ "help_link": "https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
+ "help_link_label": "Azure Portal",
+ },
+ "social_auth_microsoft_client_id": {
+ "category": "Social Login",
+ "description": "Microsoft OAuth2 application (client) ID from Azure App Registrations.",
+ "type": "string",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_microsoft_client_secret": {
+ "category": "Social Login",
+ "description": "Microsoft OAuth2 client secret from Azure App Registrations.",
+ "type": "string",
+ "sensitive": True,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_microsoft_tenant": {
+ "category": "Social Login",
+ "description": (
+ "Azure AD tenant ID or one of 'common', 'organizations', 'consumers'. "
+ "Use 'common' to allow any Microsoft account. Use a specific GUID to "
+ "restrict to a single organization."
+ ),
+ "type": "string",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_apple_enabled": {
+ "category": "Social Login",
+ "description": (
+ "Enable Sign in with Apple. Requires an Apple Developer account with "
+ "a Services ID configured for Sign in with Apple."
+ ),
+ "type": "boolean",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ "help_link": "https://developer.apple.com/account/resources/identifiers/list/serviceId",
+ "help_link_label": "Apple Developer Portal",
+ },
+ "social_auth_apple_client_id": {
+ "category": "Social Login",
+ "description": "Apple Services ID (e.g. com.example.docuelevate).",
+ "type": "string",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_apple_team_id": {
+ "category": "Social Login",
+ "description": "Apple Developer Team ID (10-character alphanumeric string).",
+ "type": "string",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_apple_key_id": {
+ "category": "Social Login",
+ "description": "Apple Sign-In private key ID from the Apple Developer Portal.",
+ "type": "string",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_apple_private_key": {
+ "category": "Social Login",
+ "description": (
+ "Apple Sign-In private key (PEM format). Generate this in the Apple Developer Portal. "
+ "Paste the entire key content including BEGIN/END headers."
+ ),
+ "type": "string",
+ "sensitive": True,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_dropbox_enabled": {
+ "category": "Social Login",
+ "description": (
+ "Enable Dropbox Sign-In. Uses the same Dropbox App you may already have "
+ "configured for storage, or a separate one."
+ ),
+ "type": "boolean",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_dropbox_client_id": {
+ "category": "Social Login",
+ "description": "Dropbox OAuth2 App Key from the Dropbox App Console.",
+ "type": "string",
+ "sensitive": False,
+ "required": False,
+ "restart_required": True,
+ },
+ "social_auth_dropbox_client_secret": {
+ "category": "Social Login",
+ "description": "Dropbox OAuth2 App Secret from the Dropbox App Console.",
+ "type": "string",
+ "sensitive": True,
+ "required": False,
+ "restart_required": True,
+ },
# AI Services
"openai_api_key": {
"category": "AI Services",
diff --git a/docs/AuthenticationSetup.md b/docs/AuthenticationSetup.md
index 5cc9a7b4..320e6191 100644
--- a/docs/AuthenticationSetup.md
+++ b/docs/AuthenticationSetup.md
@@ -20,10 +20,11 @@ For a complete list of configuration options, see the [Configuration Guide](Conf
## Authentication Methods
-DocuElevate supports two primary authentication methods:
+DocuElevate supports multiple authentication methods that can be used independently or together:
1. **Simple Authentication** - Basic username/password authentication managed by DocuElevate
2. **OpenID Connect** - Integration with identity providers like Authentik, Keycloak, or Auth0
+3. **Social Login** - Sign in with Google, Microsoft, Apple, or Dropbox accounts (see [Social Login Setup Guide](SocialLoginSetup.md))
## Session Security
@@ -189,3 +190,11 @@ If you encounter issues with authentication:
- For most providers, you can visit the `/.well-known/openid-configuration` endpoint to verify their settings
For more general configuration issues, see the [Configuration Troubleshooting Guide](ConfigurationTroubleshooting.md).
+
+## Social Login
+
+DocuElevate supports social login with Google, Microsoft, Apple, and Dropbox. Social login allows users to authenticate using their existing accounts with these providers, without needing a separate DocuElevate password.
+
+Social login can be used alongside any other authentication method (simple auth, OIDC, local signup). Each social provider is independently configured.
+
+For detailed setup instructions, prerequisites, and provider-specific configuration, see the **[Social Login Setup Guide](SocialLoginSetup.md)**.
diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md
index 009dc5a9..6a2ea003 100644
--- a/docs/ConfigurationGuide.md
+++ b/docs/ConfigurationGuide.md
@@ -335,6 +335,28 @@ Credentials are encrypted at rest using Fernet encryption.
| `AUTHENTIK_CONFIG_URL` | Configuration URL for Authentik OpenID Connect. |
| `OAUTH_PROVIDER_NAME` | Display name for the OAuth provider button. |
+### Social Login Providers
+
+Social login lets users sign in with their existing Google, Microsoft, Apple, or Dropbox accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md).
+
+| **Variable** | **Description** | **Default** |
+|---|---|---|
+| `SOCIAL_AUTH_GOOGLE_ENABLED` | Enable Google Sign-In. | `false` |
+| `SOCIAL_AUTH_GOOGLE_CLIENT_ID` | Google OAuth2 client ID from the Google Cloud Console. | *(empty)* |
+| `SOCIAL_AUTH_GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret. | *(empty)* |
+| `SOCIAL_AUTH_MICROSOFT_ENABLED` | Enable Microsoft Sign-In (Azure AD / Microsoft Entra ID). | `false` |
+| `SOCIAL_AUTH_MICROSOFT_CLIENT_ID` | Microsoft application (client) ID from Azure App Registrations. | *(empty)* |
+| `SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET` | Microsoft client secret. | *(empty)* |
+| `SOCIAL_AUTH_MICROSOFT_TENANT` | Azure AD tenant: `common`, `organizations`, `consumers`, or a tenant GUID. | `common` |
+| `SOCIAL_AUTH_APPLE_ENABLED` | Enable Sign in with Apple. | `false` |
+| `SOCIAL_AUTH_APPLE_CLIENT_ID` | Apple Services ID (e.g. `com.example.docuelevate`). | *(empty)* |
+| `SOCIAL_AUTH_APPLE_TEAM_ID` | Apple Developer Team ID. | *(empty)* |
+| `SOCIAL_AUTH_APPLE_KEY_ID` | Apple Sign-In private key ID. | *(empty)* |
+| `SOCIAL_AUTH_APPLE_PRIVATE_KEY` | Apple Sign-In private key (PEM format). | *(empty)* |
+| `SOCIAL_AUTH_DROPBOX_ENABLED` | Enable Dropbox Sign-In. | `false` |
+| `SOCIAL_AUTH_DROPBOX_CLIENT_ID` | Dropbox OAuth2 App Key. | *(empty)* |
+| `SOCIAL_AUTH_DROPBOX_CLIENT_SECRET` | Dropbox OAuth2 App Secret. | *(empty)* |
+
### Multi-User Mode
When multi-user mode is enabled, each authenticated user gets their own isolated document space.
diff --git a/docs/SocialLoginSetup.md b/docs/SocialLoginSetup.md
new file mode 100644
index 00000000..5b4523ba
--- /dev/null
+++ b/docs/SocialLoginSetup.md
@@ -0,0 +1,375 @@
+# Social Login Setup Guide
+
+This guide explains how to configure social login providers (Google, Microsoft, Apple, Dropbox) for DocuElevate. Social login lets your users sign in with their existing accounts, reducing friction and eliminating the need for separate passwords.
+
+## Overview
+
+DocuElevate supports four social login providers:
+
+| Provider | Protocol | Best For |
+|----------|----------|----------|
+| **Google** | OAuth2 / OpenID Connect | Consumers and Google Workspace organizations |
+| **Microsoft** | OAuth2 / OpenID Connect | Microsoft 365 / Azure AD organizations and personal Microsoft accounts |
+| **Apple** | OAuth2 / OpenID Connect | iOS/macOS users, privacy-focused users |
+| **Dropbox** | OAuth2 | Teams already using Dropbox as a storage destination |
+
+Each provider is **independently enabled** — you can use one, several, or all of them at the same time. Social login works alongside any other DocuElevate authentication method (simple auth, OIDC/Authentik, local signup).
+
+## Prerequisites
+
+Before configuring any social login provider, ensure:
+
+1. **Authentication is enabled**: `AUTH_ENABLED=true` in your `.env` file
+2. **Session secret is set**: `SESSION_SECRET` must be a random string of at least 32 characters
+3. **HTTPS is configured**: All social login providers require HTTPS redirect URIs in production. Use a reverse proxy (Traefik, Nginx, Caddy) with a valid TLS certificate
+4. **External hostname is set**: `EXTERNAL_HOSTNAME` must match your public domain (e.g., `docuelevate.example.com`)
+
+> **Note:** Social login users are regular (non-admin) users by default. To grant admin access, use the Admin Panel (**Settings → User Management**) after the user's first login, or configure admin groups via Authentik/OIDC.
+
+## Callback URLs
+
+Each social login provider uses a callback URL to redirect users back to DocuElevate after authentication. The callback URL pattern is:
+
+```
+https:///social-callback/
+```
+
+For example, if your DocuElevate instance is at `https://docuelevate.example.com`:
+
+| Provider | Callback URL |
+|----------|-------------|
+| Google | `https://docuelevate.example.com/social-callback/google` |
+| Microsoft | `https://docuelevate.example.com/social-callback/microsoft` |
+| Apple | `https://docuelevate.example.com/social-callback/apple` |
+| Dropbox | `https://docuelevate.example.com/social-callback/dropbox` |
+
+---
+
+## Google Sign-In
+
+### 1. Create OAuth Credentials in Google Cloud Console
+
+1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
+2. Create a new project (or select an existing one)
+3. Navigate to **APIs & Services → Credentials**
+4. Click **Create Credentials → OAuth client ID**
+5. If prompted, configure the **OAuth consent screen** first:
+ - **User Type**: External (or Internal for Google Workspace)
+ - **App name**: DocuElevate
+ - **User support email**: Your email
+ - **Authorized domains**: Your domain (e.g., `example.com`)
+ - **Scopes**: Add `email`, `profile`, and `openid`
+6. Back on the Credentials page, create an **OAuth 2.0 Client ID**:
+ - **Application type**: Web application
+ - **Name**: DocuElevate
+ - **Authorized redirect URIs**: `https://docuelevate.example.com/social-callback/google`
+7. Note the **Client ID** and **Client Secret**
+
+### 2. Configure DocuElevate
+
+Add to your `.env` file:
+
+```bash
+SOCIAL_AUTH_GOOGLE_ENABLED=true
+SOCIAL_AUTH_GOOGLE_CLIENT_ID=123456789-abcdefg.apps.googleusercontent.com
+SOCIAL_AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-your-secret-here
+```
+
+### 3. Restart DocuElevate
+
+```bash
+docker compose restart api worker
+```
+
+### Google-Specific Notes
+
+- **Google Workspace**: If you want to restrict sign-in to users in your Google Workspace organization, set the OAuth consent screen to "Internal"
+- **Verification**: Google may require app verification if you're using External user type and requesting sensitive scopes. For small teams (<100 users), you can add test users instead
+- **Unified Auth**: If you also use Google Drive as a storage destination, users who sign in with Google will already be authenticated with a Google identity — simplifying the Google Drive integration experience
+
+---
+
+## Microsoft Sign-In (Azure AD / Microsoft Entra ID)
+
+### 1. Register an Application in Azure
+
+1. Go to the [Azure Portal](https://portal.azure.com/)
+2. Navigate to **Microsoft Entra ID → App registrations**
+3. Click **New registration**
+4. Fill in:
+ - **Name**: DocuElevate
+ - **Supported account types**: Choose based on your needs:
+ - *Accounts in this organizational directory only* — single-tenant (your org only)
+ - *Accounts in any organizational directory* — multi-tenant
+ - *Accounts in any organizational directory and personal Microsoft accounts* — broadest reach
+ - **Redirect URI**: Select **Web** and enter `https://docuelevate.example.com/social-callback/microsoft`
+5. Click **Register**
+6. Note the **Application (client) ID**
+7. Navigate to **Certificates & secrets → New client secret**
+8. Add a description and expiration, then click **Add**
+9. Note the **Value** (this is your client secret — it's only shown once!)
+
+### 2. Configure API Permissions
+
+1. In your app registration, go to **API permissions**
+2. Ensure these permissions are present (they're usually added by default):
+ - `openid`
+ - `profile`
+ - `email`
+3. Click **Grant admin consent** if you're a tenant admin
+
+### 3. Configure DocuElevate
+
+Add to your `.env` file:
+
+```bash
+SOCIAL_AUTH_MICROSOFT_ENABLED=true
+SOCIAL_AUTH_MICROSOFT_CLIENT_ID=12345678-abcd-efgh-ijkl-123456789012
+SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET=your~client~secret~value
+SOCIAL_AUTH_MICROSOFT_TENANT=common
+```
+
+**Tenant options:**
+
+| Value | Who Can Sign In |
+|-------|----------------|
+| `common` | Any Microsoft account (personal + any Azure AD organization) |
+| `organizations` | Any Azure AD organization (work/school accounts only) |
+| `consumers` | Personal Microsoft accounts only (outlook.com, hotmail.com, etc.) |
+| `` | Only users in a specific Azure AD tenant (use the GUID from Azure Portal) |
+
+### 4. Restart DocuElevate
+
+```bash
+docker compose restart api worker
+```
+
+### Microsoft-Specific Notes
+
+- **Client secret expiration**: Azure AD client secrets expire (max 2 years). Set a calendar reminder to rotate them before they expire
+- **Conditional Access**: If your organization uses Azure AD Conditional Access policies, social login will respect them
+- **Unified Auth**: If you also use OneDrive as a storage destination, users who sign in with Microsoft will already have a Microsoft identity — potentially simplifying OneDrive integration
+
+---
+
+## Apple Sign-In
+
+Apple Sign-In requires an Apple Developer account ($99/year) and more setup than other providers.
+
+### 1. Configure in Apple Developer Portal
+
+1. Go to the [Apple Developer Portal](https://developer.apple.com/account/)
+2. Navigate to **Certificates, Identifiers & Profiles → Identifiers**
+3. Click **+** and select **App IDs** → Register an App ID:
+ - **Description**: DocuElevate
+ - **Bundle ID**: e.g., `com.example.docuelevate`
+ - Enable **Sign In with Apple** capability
+4. Click **+** again and select **Services IDs**:
+ - **Description**: DocuElevate Web
+ - **Identifier**: e.g., `com.example.docuelevate.web` (this is your Client ID)
+ - Enable **Sign In with Apple**
+ - Click **Configure** next to Sign In with Apple:
+ - **Primary App ID**: Select the App ID created above
+ - **Domains**: `docuelevate.example.com`
+ - **Return URLs**: `https://docuelevate.example.com/social-callback/apple`
+5. Click **Save** and **Continue** → **Register**
+6. Navigate to **Keys** → Click **+** to create a new key:
+ - **Key Name**: DocuElevate Sign-In
+ - Enable **Sign In with Apple**
+ - Click **Configure** and select the App ID created above
+ - Click **Continue** → **Register**
+ - **Download the private key file** (`.p8`) — you can only download it once!
+ - Note the **Key ID**
+7. Note your **Team ID** (shown in the top-right corner of the Developer Portal)
+
+### 2. Configure DocuElevate
+
+Add to your `.env` file:
+
+```bash
+SOCIAL_AUTH_APPLE_ENABLED=true
+SOCIAL_AUTH_APPLE_CLIENT_ID=com.example.docuelevate.web
+SOCIAL_AUTH_APPLE_TEAM_ID=ABCDE12345
+SOCIAL_AUTH_APPLE_KEY_ID=FGHIJ67890
+SOCIAL_AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
+MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...
+...your key content here...
+-----END PRIVATE KEY-----"
+```
+
+> **Tip:** You can also store the private key as a single line with `\n` for line breaks:
+> ```bash
+> SOCIAL_AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMG...\n-----END PRIVATE KEY-----"
+> ```
+
+### 3. Restart DocuElevate
+
+```bash
+docker compose restart api worker
+```
+
+### Apple-Specific Notes
+
+- **Email relay**: Apple offers a "Hide My Email" feature that provides a relay email address (e.g., `abc123@privaterelay.appleid.com`). DocuElevate accepts these addresses
+- **First login only**: Apple sends the user's name only on the very first authorization. If the user revokes and re-authorizes, their name may not be sent again
+- **Developer account required**: You need an Apple Developer account ($99/year) to use Sign In with Apple
+- **Key rotation**: Apple private keys don't expire, but if you suspect compromise, revoke the key in the Developer Portal and create a new one
+
+---
+
+## Dropbox Sign-In
+
+### 1. Create a Dropbox App
+
+1. Go to the [Dropbox App Console](https://www.dropbox.com/developers/apps)
+2. Click **Create app**
+3. Choose:
+ - **API**: Scoped access
+ - **Access type**: Full Dropbox (or App folder, depending on your needs)
+ - **Name**: DocuElevate Auth (or reuse your existing Dropbox storage app)
+4. In the app settings, go to the **OAuth 2** section:
+ - Add **Redirect URI**: `https://docuelevate.example.com/social-callback/dropbox`
+5. Note the **App key** (this is your Client ID) and **App secret** (this is your Client Secret)
+
+> **Tip:** If you already have a Dropbox app configured for DocuElevate's storage integration, you can reuse the same app — just add the social login redirect URI. Alternatively, create a separate app for authentication to keep concerns separated.
+
+### 2. Configure DocuElevate
+
+Add to your `.env` file:
+
+```bash
+SOCIAL_AUTH_DROPBOX_ENABLED=true
+SOCIAL_AUTH_DROPBOX_CLIENT_ID=your_dropbox_app_key
+SOCIAL_AUTH_DROPBOX_CLIENT_SECRET=your_dropbox_app_secret
+```
+
+### 3. Restart DocuElevate
+
+```bash
+docker compose restart api worker
+```
+
+### Dropbox-Specific Notes
+
+- **Unified Auth**: If you also use Dropbox as a storage destination, authenticating via Dropbox establishes the user's Dropbox identity — making it easier to manage Dropbox storage integration
+- **App review**: Dropbox may require app review for production apps with more than 50 users. See [Dropbox App Review](https://www.dropbox.com/developers/reference/developer-guide#app-review)
+- **Personal vs. Business**: The same app works for both personal Dropbox and Dropbox Business accounts
+
+---
+
+## Unified Authentication and Storage
+
+One of the key advantages of social login in DocuElevate is the potential for **unified authentication** — using the same identity for both signing in and accessing cloud storage destinations:
+
+| Social Login Provider | Related Storage Destination | Benefit |
+|---|---|---|
+| Google | Google Drive | User already has a Google identity for Drive integration |
+| Microsoft | OneDrive | User already has a Microsoft identity for OneDrive integration |
+| Dropbox | Dropbox | User already has a Dropbox identity for Dropbox integration |
+| Apple | *(none)* | Provides a familiar, privacy-respecting login option |
+
+When a user signs in with a social provider that matches a configured storage destination, the administrator can leverage the same OAuth credentials or simplify the integration setup. Note that the storage integration credentials are configured separately in the admin settings — social login establishes the user's identity, not their storage permissions.
+
+## Combining Multiple Auth Methods
+
+DocuElevate supports running multiple authentication methods simultaneously:
+
+```
+┌──────────────────────────────────────────────────┐
+│ Login Page │
+├──────────────────────────────────────────────────┤
+│ Username / Password form (always shown) │
+│ │
+│ ─── Or continue with ─── │
+│ │
+│ [Authentik SSO] (if OIDC configured) │
+│ [Sign in with Google] (if Google enabled) │
+│ [Sign in with Microsoft] (if Microsoft enabled) │
+│ [Sign in with Apple] (if Apple enabled) │
+│ [Sign in with Dropbox] (if Dropbox enabled) │
+│ │
+│ [Create account] (if local signup enabled) │
+└──────────────────────────────────────────────────┘
+```
+
+All methods create or update the same `UserProfile` record, so a user is consistently identified regardless of how they sign in.
+
+## Admin Management
+
+Social login users appear in the **Admin → User Management** panel like any other user. Admins can:
+
+- View which provider a user authenticated with
+- Block or unblock social login users
+- Set upload limits and subscription tiers
+- Grant admin privileges (social login users are never automatically admin)
+
+## Security Considerations
+
+1. **HTTPS is required**: All social login providers require HTTPS callback URLs in production
+2. **Credentials are sensitive**: Store client secrets securely — use environment variables, never commit them to source control
+3. **Least privilege**: Only request the scopes you need (DocuElevate requests `openid`, `profile`, and `email`)
+4. **Rotate secrets**: Set calendar reminders to rotate OAuth client secrets before they expire (especially Microsoft, which has a max 2-year expiration)
+5. **Monitor logins**: Check the DocuElevate audit log for unusual login patterns
+6. **Social login users are not admins**: Admin access must be explicitly granted by an existing admin
+
+## Troubleshooting
+
+### Common Issues
+
+1. **"Unknown social provider" error**
+ - The provider is not enabled or credentials are missing
+ - Check that `SOCIAL_AUTH__ENABLED=true` is set
+ - Verify client ID and secret are configured
+
+2. **"Could not retrieve email from provider" error**
+ - The provider didn't return an email address
+ - For Google: Ensure `email` scope is included (it is by default)
+ - For Apple: User may have chosen "Hide My Email" — this is expected and should still work
+ - For Dropbox: Ensure the app has permission to read the user's email
+
+3. **Redirect URI mismatch**
+ - The callback URL registered with the provider must exactly match what DocuElevate generates
+ - Check your `EXTERNAL_HOSTNAME` setting
+ - Ensure you're using HTTPS in production
+ - The callback URL format is: `https:///social-callback/`
+
+4. **"Social login failed" error**
+ - Check DocuElevate logs (`docker compose logs api`) for detailed error messages
+ - Verify the provider's OAuth app is not suspended or in development mode
+ - For Google: Check if the OAuth consent screen needs verification
+ - For Microsoft: Ensure admin consent was granted for the required permissions
+
+5. **User can't log in after changing provider settings**
+ - After changing social login configuration, restart DocuElevate: `docker compose restart api worker`
+ - Social login settings require a restart to take effect (`restart_required: true`)
+
+### Debug Checklist
+
+- [ ] `AUTH_ENABLED=true` is set
+- [ ] `SESSION_SECRET` is at least 32 characters
+- [ ] `EXTERNAL_HOSTNAME` matches your public domain
+- [ ] Provider-specific `_ENABLED=true` is set
+- [ ] Client ID and secret are correctly configured (no extra spaces)
+- [ ] Callback URL is registered with the provider
+- [ ] HTTPS is working on your domain
+- [ ] DocuElevate has been restarted after configuration changes
+
+## Environment Variable Reference
+
+| Variable | Required | Description |
+|---|---|---|
+| `SOCIAL_AUTH_GOOGLE_ENABLED` | No | Enable Google Sign-In (`true`/`false`). Default: `false` |
+| `SOCIAL_AUTH_GOOGLE_CLIENT_ID` | When Google enabled | Google OAuth2 client ID |
+| `SOCIAL_AUTH_GOOGLE_CLIENT_SECRET` | When Google enabled | Google OAuth2 client secret |
+| `SOCIAL_AUTH_MICROSOFT_ENABLED` | No | Enable Microsoft Sign-In (`true`/`false`). Default: `false` |
+| `SOCIAL_AUTH_MICROSOFT_CLIENT_ID` | When Microsoft enabled | Azure AD application (client) ID |
+| `SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET` | When Microsoft enabled | Azure AD client secret |
+| `SOCIAL_AUTH_MICROSOFT_TENANT` | No | Azure AD tenant. Default: `common` |
+| `SOCIAL_AUTH_APPLE_ENABLED` | No | Enable Apple Sign-In (`true`/`false`). Default: `false` |
+| `SOCIAL_AUTH_APPLE_CLIENT_ID` | When Apple enabled | Apple Services ID |
+| `SOCIAL_AUTH_APPLE_TEAM_ID` | When Apple enabled | Apple Developer Team ID |
+| `SOCIAL_AUTH_APPLE_KEY_ID` | When Apple enabled | Apple Sign-In key ID |
+| `SOCIAL_AUTH_APPLE_PRIVATE_KEY` | When Apple enabled | Apple Sign-In private key (PEM) |
+| `SOCIAL_AUTH_DROPBOX_ENABLED` | No | Enable Dropbox Sign-In (`true`/`false`). Default: `false` |
+| `SOCIAL_AUTH_DROPBOX_CLIENT_ID` | When Dropbox enabled | Dropbox App Key |
+| `SOCIAL_AUTH_DROPBOX_CLIENT_SECRET` | When Dropbox enabled | Dropbox App Secret |
diff --git a/frontend/templates/login.html b/frontend/templates/login.html
index bcfbe815..5faad44d 100644
--- a/frontend/templates/login.html
+++ b/frontend/templates/login.html
@@ -75,7 +75,8 @@