From d4f3e1155f9ae17cc3c373757c05ab63f175aa71 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 23 May 2026 19:56:19 +0200 Subject: [PATCH] feat: add workspace onboarding templates --- backend/app/api/api_v1/api.py | 2 + .../app/api/api_v1/endpoints/onboarding.py | 126 ++++ backend/app/services/workspace_onboarding.py | 632 ++++++++++++++++++ .../app/tests/test_workspace_onboarding.py | 167 +++++ docs/milestones.md | 4 +- docs/reference/api.md | 53 ++ docs/reference/workspaces.md | 26 + 7 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/api_v1/endpoints/onboarding.py create mode 100644 backend/app/services/workspace_onboarding.py create mode 100644 backend/app/tests/test_workspace_onboarding.py diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index 84afa61..fc02e01 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -10,6 +10,7 @@ from app.api.api_v1.endpoints import ( imap, integrations, mail_sources, + onboarding, public, reports, settings, @@ -36,6 +37,7 @@ api_router.include_router(imap.router, prefix="/imap", tags=["imap"]) api_router.include_router(integrations.router, prefix="/integrations", tags=["integrations"]) api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) api_router.include_router(mail_sources.router, prefix="/mail-sources", tags=["mail-sources"]) +api_router.include_router(onboarding.router, prefix="/onboarding", tags=["onboarding"]) api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) api_router.include_router(tls_reports.router, prefix="/tls-reports", tags=["tls-reports"]) api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"]) diff --git a/backend/app/api/api_v1/endpoints/onboarding.py b/backend/app/api/api_v1/endpoints/onboarding.py new file mode 100644 index 0000000..d33d385 --- /dev/null +++ b/backend/app/api/api_v1/endpoints/onboarding.py @@ -0,0 +1,126 @@ +"""Workspace onboarding template endpoints.""" + +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.security import require_admin_auth +from app.services.workspace_access import ( + PERMISSION_WORKSPACE_ADMIN, + require_workspace_permission, +) +from app.services.workspace_onboarding import ( + apply_onboarding_plan, + build_onboarding_plan, + list_onboarding_templates, + public_onboarding_plan, +) + +router = APIRouter() + + +class OnboardingWorkspace(BaseModel): + """Workspace target for an onboarding plan.""" + + slug: Optional[str] = None + name: str + description: Optional[str] = None + + +class OnboardingPlanRequest(BaseModel): + """Request body for rendering or applying an onboarding template.""" + + template_id: str + workspace: OnboardingWorkspace + variables: Dict[str, Any] = Field(default_factory=dict) + domains: Optional[List[Dict[str, Any]]] = None + mail_sources: Optional[List[Dict[str, Any]]] = None + notification_defaults: Optional[Dict[str, Any]] = None + overwrite_existing: bool = False + + +class OnboardingTemplatesResponse(BaseModel): + """Available workspace onboarding templates.""" + + templates: List[Dict[str, Any]] + + +class OnboardingPlanResponse(BaseModel): + """Rendered onboarding plan response.""" + + plan: Dict[str, Any] + + +class OnboardingApplyResponse(BaseModel): + """Applied onboarding plan response.""" + + result: Dict[str, Any] + + +def _build_plan_or_422(payload: OnboardingPlanRequest) -> Dict[str, Any]: + try: + plan = build_onboarding_plan( + template_id=payload.template_id, + workspace=payload.workspace.model_dump(), + variables=payload.variables, + domains=payload.domains, + mail_sources=payload.mail_sources, + notification_defaults=payload.notification_defaults, + overwrite_existing=payload.overwrite_existing, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + if plan["errors"]: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=plan["errors"], + ) + return plan + + +@router.get("/templates", response_model=OnboardingTemplatesResponse) +async def get_onboarding_templates( + _auth: dict = Depends(require_admin_auth), +) -> OnboardingTemplatesResponse: + """Return versioned workspace onboarding templates.""" + require_workspace_permission(_auth, PERMISSION_WORKSPACE_ADMIN) + return {"templates": list_onboarding_templates()} + + +@router.post("/preview", response_model=OnboardingPlanResponse) +async def preview_onboarding_plan( + payload: OnboardingPlanRequest, + _auth: dict = Depends(require_admin_auth), +) -> OnboardingPlanResponse: + """Render an onboarding template without changing the database.""" + require_workspace_permission(_auth, PERMISSION_WORKSPACE_ADMIN) + return {"plan": public_onboarding_plan(_build_plan_or_422(payload))} + + +@router.post("/apply", response_model=OnboardingApplyResponse) +async def apply_workspace_onboarding( + payload: OnboardingPlanRequest, + request: Request, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> OnboardingApplyResponse: + """Apply a workspace onboarding template.""" + require_workspace_permission(_auth, PERMISSION_WORKSPACE_ADMIN) + plan = _build_plan_or_422(payload) + try: + result = apply_onboarding_plan(db, plan=plan, auth_context=_auth, request=request) + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Onboarding plan conflicts with existing data", + ) from exc + if not result.get("applied"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=result, + ) + return {"result": result} diff --git a/backend/app/services/workspace_onboarding.py b/backend/app/services/workspace_onboarding.py new file mode 100644 index 0000000..6ff1808 --- /dev/null +++ b/backend/app/services/workspace_onboarding.py @@ -0,0 +1,632 @@ +"""Workspace onboarding templates and apply helpers.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models.domain import Domain +from app.models.mail_source import MailSource +from app.models.setting import Setting +from app.models.workspace import Workspace +from app.services.workspace_audit import record_workspace_audit_log, sanitize_audit_details +from app.services.workspaces import normalize_workspace_slug, workspace_domain_query +from app.utils.domain_validator import validate_domain_config + +ONBOARDING_SCHEMA_VERSION = "dmarq.workspace_onboarding.v1" + +SAFE_NOTIFICATION_DEFAULTS = { + "notifications.apprise_enabled", + "notifications.min_send_interval_minutes", + "notifications.redact_pii_enabled", + "notifications.alert_new_sources_enabled", + "notifications.alert_compliance_drop_enabled", + "notifications.alert_compliance_drop_points", + "notifications.alert_failure_threshold_enabled", + "notifications.alert_failure_threshold_count", + "notifications.alert_missing_reports_enabled", + "notifications.alert_missing_reports_days", + "notifications.summary_daily_enabled", + "notifications.summary_weekly_enabled", + "notifications.summary_send_hour_utc", + "notifications.summary_weekday_utc", +} + +NOTIFICATION_SETTING_META = { + "notifications.apprise_enabled": ("Send notifications through Apprise", "boolean"), + "notifications.min_send_interval_minutes": ("Minimum minutes between notifications", "integer"), + "notifications.redact_pii_enabled": ("Redact PII in outbound notifications", "boolean"), + "notifications.alert_new_sources_enabled": ("Alert when new senders appear", "boolean"), + "notifications.alert_compliance_drop_enabled": ("Alert on compliance drops", "boolean"), + "notifications.alert_compliance_drop_points": ("Compliance drop threshold", "integer"), + "notifications.alert_failure_threshold_enabled": ("Alert on high failure volume", "boolean"), + "notifications.alert_failure_threshold_count": ("Daily failure-count threshold", "integer"), + "notifications.alert_missing_reports_enabled": ("Alert when reports stop arriving", "boolean"), + "notifications.alert_missing_reports_days": ("Days without reports before alerting", "integer"), + "notifications.summary_daily_enabled": ("Send daily DMARC summaries", "boolean"), + "notifications.summary_weekly_enabled": ("Send weekly DMARC summaries", "boolean"), + "notifications.summary_send_hour_utc": ("UTC hour for scheduled summaries", "integer"), + "notifications.summary_weekday_utc": ("UTC weekday for weekly summaries", "integer"), +} + +WORKSPACE_ONBOARDING_TEMPLATES: List[Dict[str, Any]] = [ + { + "id": "standard_monitoring", + "name": "Standard DMARC Monitoring", + "description": ( + "Create one monitored domain, one disabled IMAP mail source, " + "and safe notification defaults." + ), + "variables": [ + {"name": "domain", "required": True, "example": "example.com"}, + {"name": "workspace_name", "required": False, "example": "Example Client"}, + {"name": "report_mailbox", "required": False, "example": "dmarc@example.com"}, + {"name": "imap_server", "required": False, "example": "imap.example.com"}, + {"name": "imap_username", "required": False, "example": "dmarc@example.com"}, + {"name": "imap_password", "required": False, "secret": True}, + ], + "domains": [ + { + "name": "{domain}", + "description": "Primary DMARC domain for {workspace_name}", + "dkim_selectors": ["google", "selector1"], + } + ], + "mail_sources": [ + { + "name": "{workspace_name} DMARC inbox", + "method": "IMAP", + "server": "{imap_server}", + "port": 993, + "username": "{imap_username}", + "password": "{imap_password}", + "folder": "INBOX", + "use_ssl": True, + "polling_interval": 60, + "enabled": False, + } + ], + "notification_defaults": { + "notifications.apprise_enabled": "false", + "notifications.redact_pii_enabled": "true", + "notifications.alert_new_sources_enabled": "true", + "notifications.alert_compliance_drop_enabled": "true", + "notifications.alert_compliance_drop_points": "10", + "notifications.alert_failure_threshold_enabled": "true", + "notifications.alert_failure_threshold_count": "100", + "notifications.alert_missing_reports_enabled": "true", + "notifications.alert_missing_reports_days": "2", + "notifications.summary_weekly_enabled": "true", + "notifications.summary_send_hour_utc": "8", + "notifications.summary_weekday_utc": "0", + }, + "checklist": [ + { + "id": "verify-domain-dns", + "category": "domains", + "title": "Verify DMARC, SPF, and DKIM DNS posture", + "description": ( + "Open the domain DNS view and confirm DMARC, SPF, and each " + "configured DKIM selector resolve." + ), + }, + { + "id": "connect-mail-source", + "category": "mail_sources", + "title": "Connect the DMARC report inbox", + "description": ( + "Add credentials or complete OAuth, run a connection test, " + "then enable polling." + ), + }, + { + "id": "run-initial-import", + "category": "mail_sources", + "title": "Run an initial DMARC import", + "description": ( + "Trigger a manual import for the mail source and confirm " "reports are parsed." + ), + }, + { + "id": "configure-notification-target", + "category": "notifications", + "title": "Configure and test notification delivery", + "description": ( + "Add Apprise targets, send a test notification, and confirm " + "alert thresholds match the client." + ), + }, + ], + }, + { + "id": "dns_only_assessment", + "name": "DNS Posture Assessment", + "description": ( + "Create monitored domains and a validation checklist without " "mailbox ingestion." + ), + "variables": [ + {"name": "domain", "required": True, "example": "example.com"}, + {"name": "workspace_name", "required": False, "example": "Example Client"}, + ], + "domains": [ + { + "name": "{domain}", + "description": "DNS posture assessment for {workspace_name}", + "dkim_selectors": [], + } + ], + "mail_sources": [], + "notification_defaults": { + "notifications.apprise_enabled": "false", + "notifications.alert_missing_reports_enabled": "false", + "notifications.summary_weekly_enabled": "false", + }, + "checklist": [ + { + "id": "verify-domain-dns", + "category": "domains", + "title": "Verify published authentication records", + "description": ( + "Review DMARC, SPF, DKIM, MTA-STS, and BIMI posture before " + "recommending changes." + ), + }, + { + "id": "document-report-inbox", + "category": "mail_sources", + "title": "Document the future DMARC report inbox", + "description": ( + "Confirm where aggregate reports are delivered before " "enabling ingestion." + ), + }, + ], + }, +] + + +class _SafeFormatDict(dict): + def __missing__(self, key): + return "{" + key + "}" + + +def _render_value(value: Any, variables: Dict[str, Any]) -> Any: + if isinstance(value, str): + return value.format_map( + _SafeFormatDict({k: "" if v is None else v for k, v in variables.items()}) + ) + if isinstance(value, list): + return [_render_value(item, variables) for item in value] + if isinstance(value, dict): + return {key: _render_value(item, variables) for key, item in value.items()} + return value + + +def _clean_optional_strings(value: Any) -> Any: + if isinstance(value, dict): + return {key: _clean_optional_strings(item) for key, item in value.items()} + if isinstance(value, list): + return [_clean_optional_strings(item) for item in value] + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return value + + +def _template_by_id(template_id: str) -> Dict[str, Any]: + for template in WORKSPACE_ONBOARDING_TEMPLATES: + if template["id"] == template_id: + return copy.deepcopy(template) + raise ValueError(f"Unknown onboarding template: {template_id}") + + +def list_onboarding_templates() -> List[Dict[str, Any]]: + """Return static workspace onboarding templates.""" + return copy.deepcopy(WORKSPACE_ONBOARDING_TEMPLATES) + + +def public_onboarding_plan(plan: Dict[str, Any]) -> Dict[str, Any]: + """Return an API-safe plan with secret-like values redacted.""" + return sanitize_audit_details(plan) + + +def _workspace_context( + workspace: Dict[str, Any], + variables: Optional[Dict[str, Any]], +) -> Tuple[str, str, Dict[str, Any]]: + context = {str(key): value for key, value in (variables or {}).items()} + workspace_name = str(workspace.get("name") or context.get("workspace_name") or "").strip() + slug = normalize_workspace_slug(str(workspace.get("slug") or workspace_name)) + context.setdefault("workspace_name", workspace_name or slug) + context.setdefault("domain", "") + context.setdefault("report_mailbox", "") + context.setdefault("imap_server", "") + context.setdefault("imap_username", context.get("report_mailbox", "")) + context.setdefault("imap_password", "") + return slug, workspace_name, context + + +def _required_variable_errors(template: Dict[str, Any], variables: Dict[str, Any]) -> List[str]: + errors = [] + for variable in template.get("variables", []): + name = variable["name"] + if variable.get("required") and not str(variables.get(name) or "").strip(): + errors.append(f"template variable is required: {name}") + return errors + + +def _render_sections( + template: Dict[str, Any], + variables: Dict[str, Any], + *, + domains: Optional[List[Dict[str, Any]]], + mail_sources: Optional[List[Dict[str, Any]]], + notification_defaults: Optional[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: + rendered_domains = _clean_optional_strings( + _render_value(domains if domains is not None else template.get("domains", []), variables) + ) + rendered_sources = _clean_optional_strings( + _render_value( + mail_sources if mail_sources is not None else template.get("mail_sources", []), + variables, + ) + ) + rendered_notifications = _clean_optional_strings( + _render_value( + ( + notification_defaults + if notification_defaults is not None + else template.get("notification_defaults", {}) + ), + variables, + ) + ) + return rendered_domains, rendered_sources, rendered_notifications + + +def _validated_domains( + rendered_domains: Iterable[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], List[str]]: + errors = [] + validated = [] + for item in rendered_domains: + name = str(item.get("name") or "").strip().strip(".").lower() + validation = validate_domain_config( + {"name": name, "description": item.get("description") or ""} + ) + if not validation["valid"]: + errors.append(f"invalid domain {name or ''}: {validation['errors']}") + continue + selectors = [ + str(selector).strip() + for selector in item.get("dkim_selectors") or [] + if str(selector).strip() + ] + validated.append( + { + "name": name, + "description": item.get("description"), + "dkim_selectors": selectors, + } + ) + return validated, errors + + +def _validated_mail_sources( + rendered_sources: Iterable[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], List[str]]: + errors = [] + validated = [] + for item in rendered_sources: + name = str(item.get("name") or "").strip() + method = str(item.get("method") or "IMAP").strip().upper() + if not name: + errors.append("mail source name is required") + continue + if method not in {"IMAP", "POP3", "GMAIL_API", "M365_GRAPH"}: + errors.append(f"unsupported mail source method: {method}") + continue + validated.append( + { + "name": name, + "method": method, + "server": item.get("server"), + "port": int(item.get("port") or 993), + "username": item.get("username"), + "password": item.get("password"), + "use_ssl": bool(item.get("use_ssl", True)), + "folder": item.get("folder") or "INBOX", + "polling_interval": int(item.get("polling_interval") or 60), + "enabled": bool(item.get("enabled", False)), + "gmail_client_id": item.get("gmail_client_id"), + "gmail_client_secret": item.get("gmail_client_secret"), + "m365_tenant_id": item.get("m365_tenant_id") or "common", + "m365_client_id": item.get("m365_client_id"), + "m365_client_secret": item.get("m365_client_secret"), + "m365_mailbox": item.get("m365_mailbox"), + "m365_folder_id": item.get("m365_folder_id"), + } + ) + return validated, errors + + +def _validated_notification_defaults( + rendered_notifications: Dict[str, Any], +) -> Tuple[Dict[str, str], List[str]]: + errors = [] + safe_notifications = {} + for key, value in (rendered_notifications or {}).items(): + if key not in SAFE_NOTIFICATION_DEFAULTS: + errors.append(f"unsupported notification default: {key}") + continue + safe_notifications[key] = "" if value is None else str(value) + return safe_notifications, errors + + +def _validated_sections( + rendered_domains: Iterable[Dict[str, Any]], + rendered_sources: Iterable[Dict[str, Any]], + rendered_notifications: Dict[str, Any], +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, str], List[str]]: + domains, domain_errors = _validated_domains(rendered_domains) + sources, source_errors = _validated_mail_sources(rendered_sources) + notifications, notification_errors = _validated_notification_defaults(rendered_notifications) + return domains, sources, notifications, domain_errors + source_errors + notification_errors + + +def build_onboarding_plan( # pylint: disable=too-many-locals + *, + template_id: str, + workspace: Dict[str, Any], + variables: Optional[Dict[str, Any]] = None, + domains: Optional[List[Dict[str, Any]]] = None, + mail_sources: Optional[List[Dict[str, Any]]] = None, + notification_defaults: Optional[Dict[str, Any]] = None, + overwrite_existing: bool = False, +) -> Dict[str, Any]: + """Render and validate a workspace onboarding plan.""" + template = _template_by_id(template_id) + slug, workspace_name, variables = _workspace_context(workspace, variables) + errors: List[str] = [] + if not slug: + errors.append("workspace.slug or workspace.name is required") + if not workspace_name: + errors.append("workspace.name is required") + errors.extend(_required_variable_errors(template, variables)) + rendered_domains, rendered_sources, rendered_notifications = _render_sections( + template, + variables, + domains=domains, + mail_sources=mail_sources, + notification_defaults=notification_defaults, + ) + validated_domains, validated_sources, safe_notifications, section_errors = _validated_sections( + rendered_domains, rendered_sources, rendered_notifications + ) + errors.extend(section_errors) + + return { + "schema_version": ONBOARDING_SCHEMA_VERSION, + "template_id": template_id, + "template_name": template["name"], + "workspace": { + "slug": slug, + "name": workspace_name, + "description": workspace.get("description"), + }, + "domains": validated_domains, + "mail_sources": validated_sources, + "notification_defaults": safe_notifications, + "checklist": copy.deepcopy(template.get("checklist", [])), + "overwrite_existing": overwrite_existing, + "errors": errors, + } + + +def _ensure_workspace(db: Session, workspace_plan: Dict[str, Any]) -> Tuple[Workspace, str]: + workspace = db.query(Workspace).filter(Workspace.slug == workspace_plan["slug"]).first() + if workspace: + return workspace, "existing" + workspace = Workspace( + slug=workspace_plan["slug"], + name=workspace_plan["name"], + description=workspace_plan.get("description"), + active=True, + ) + db.add(workspace) + db.flush() + return workspace, "created" + + +def _apply_domains( + db: Session, + *, + workspace: Workspace, + domain_plans: Iterable[Dict[str, Any]], +) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for item in domain_plans: + existing = workspace_domain_query(db, workspace).filter(Domain.name == item["name"]).first() + if existing: + results.append({"name": item["name"], "status": "existing", "id": existing.id}) + continue + owned_elsewhere = ( + db.query(Domain) + .filter(Domain.name == item["name"], Domain.workspace_id != workspace.id) + .first() + ) + if owned_elsewhere: + results.append( + { + "name": item["name"], + "status": "conflict", + "message": "Domain is already owned by another workspace", + } + ) + continue + domain = Domain( + workspace_id=workspace.id, + name=item["name"], + description=item.get("description"), + dkim_selectors=",".join(item.get("dkim_selectors") or []) or None, + active=True, + verified=False, + ) + db.add(domain) + db.flush() + results.append({"name": domain.name, "status": "created", "id": domain.id}) + return results + + +def _apply_mail_sources( + db: Session, + *, + workspace: Workspace, + source_plans: Iterable[Dict[str, Any]], +) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for item in source_plans: + existing = ( + db.query(MailSource) + .filter(MailSource.workspace_id == workspace.id, MailSource.name == item["name"]) + .first() + ) + if existing: + results.append({"name": item["name"], "status": "existing", "id": existing.id}) + continue + source = MailSource( + workspace_id=workspace.id, + name=item["name"], + method=item["method"], + server=item.get("server"), + port=item.get("port") or 993, + username=item.get("username"), + password=item.get("password"), + use_ssl=item.get("use_ssl", True), + folder=item.get("folder") or "INBOX", + polling_interval=item.get("polling_interval") or 60, + enabled=item.get("enabled", False), + gmail_client_id=item.get("gmail_client_id"), + gmail_client_secret=item.get("gmail_client_secret"), + m365_tenant_id=item.get("m365_tenant_id") or "common", + m365_client_id=item.get("m365_client_id"), + m365_client_secret=item.get("m365_client_secret"), + m365_mailbox=item.get("m365_mailbox"), + m365_folder_id=item.get("m365_folder_id"), + ) + db.add(source) + db.flush() + results.append({"name": source.name, "status": "created", "id": source.id}) + return results + + +def _apply_notification_defaults( + db: Session, + *, + defaults: Dict[str, str], + overwrite_existing: bool, +) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for key, value in defaults.items(): + row = db.query(Setting).filter(Setting.key == key).first() + if row and not overwrite_existing: + results.append({"key": key, "status": "existing"}) + continue + if row: + row.value = value + results.append({"key": key, "status": "updated"}) + continue + description, value_type = NOTIFICATION_SETTING_META.get( + key, ("Workspace onboarding default", "string") + ) + db.add( + Setting( + key=key, + value=value, + description=description, + value_type=value_type, + category="notifications", + ) + ) + results.append({"key": key, "status": "created"}) + return results + + +def apply_onboarding_plan( + db: Session, + *, + plan: Dict[str, Any], + auth_context: Optional[Dict[str, Any]], + request=None, +) -> Dict[str, Any]: + """Apply a validated onboarding plan and return an API-safe result.""" + if plan.get("errors"): + raise ValueError("Cannot apply an invalid onboarding plan") + try: + workspace, workspace_status = _ensure_workspace(db, plan["workspace"]) + domain_results = _apply_domains(db, workspace=workspace, domain_plans=plan["domains"]) + source_results = _apply_mail_sources( + db, + workspace=workspace, + source_plans=plan["mail_sources"], + ) + notification_results = _apply_notification_defaults( + db, + defaults=plan["notification_defaults"], + overwrite_existing=bool(plan.get("overwrite_existing")), + ) + conflicts = [item for item in domain_results if item["status"] == "conflict"] + if conflicts: + db.rollback() + return { + "applied": False, + "workspace": plan["workspace"], + "errors": [item["message"] for item in conflicts], + "results": {"domains": domain_results}, + } + record_workspace_audit_log( + db, + workspace=workspace, + action="workspace.onboarding_applied", + entity_type="workspace", + entity_id=workspace.id, + entity_name=workspace.slug, + details={ + "template_id": plan["template_id"], + "workspace_status": workspace_status, + "domains": domain_results, + "mail_sources": source_results, + "notification_defaults": notification_results, + "checklist_ids": [item["id"] for item in plan.get("checklist", [])], + }, + auth_context=auth_context, + request=request, + ) + db.commit() + except IntegrityError: + db.rollback() + raise + + db.refresh(workspace) + return public_onboarding_plan( + { + "applied": True, + "schema_version": plan["schema_version"], + "template_id": plan["template_id"], + "workspace": { + "id": workspace.id, + "slug": workspace.slug, + "name": workspace.name, + "status": workspace_status, + }, + "results": { + "domains": domain_results, + "mail_sources": source_results, + "notification_defaults": notification_results, + }, + "checklist": plan.get("checklist", []), + } + ) diff --git a/backend/app/tests/test_workspace_onboarding.py b/backend/app/tests/test_workspace_onboarding.py new file mode 100644 index 0000000..f5ca595 --- /dev/null +++ b/backend/app/tests/test_workspace_onboarding.py @@ -0,0 +1,167 @@ +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models.domain import Domain +from app.models.mail_source import MailSource +from app.models.setting import Setting +from app.models.workspace import Workspace +from app.models.workspace_access import WorkspaceAuditLog + + +def _standard_payload(**overrides): + payload = { + "template_id": "standard_monitoring", + "workspace": { + "slug": "Client One", + "name": "Client One", + "description": "Managed client workspace", + }, + "variables": { + "domain": "Example.COM", + "report_mailbox": "dmarc@example.com", + "imap_server": "imap.example.com", + "imap_password": "super-secret-password", + }, + } + payload.update(overrides) + return payload + + +def test_onboarding_templates_are_available(authed_client: TestClient): + """Operators can discover the versioned onboarding template bundle.""" + response = authed_client.get("/api/v1/onboarding/templates") + + assert response.status_code == 200 + templates = response.json()["templates"] + assert {template["id"] for template in templates} >= { + "standard_monitoring", + "dns_only_assessment", + } + standard = next(template for template in templates if template["id"] == "standard_monitoring") + assert standard["domains"] + assert standard["mail_sources"] + assert standard["notification_defaults"] + assert standard["checklist"] + + +def test_onboarding_preview_renders_without_persisting( + authed_client: TestClient, + db_session: Session, +): + """Preview renders a safe plan and leaves the database unchanged.""" + response = authed_client.post("/api/v1/onboarding/preview", json=_standard_payload()) + + assert response.status_code == 200 + plan = response.json()["plan"] + assert plan["workspace"]["slug"] == "client-one" + assert plan["domains"][0]["name"] == "example.com" + assert plan["mail_sources"][0]["password"] == "[redacted]" + assert "super-secret-password" not in str(plan) + assert db_session.query(Workspace).count() == 0 + assert db_session.query(Domain).count() == 0 + + +def test_onboarding_rejects_invalid_domain(authed_client: TestClient): + """Invalid rendered domains fail before any apply attempt.""" + payload = _standard_payload(variables={"domain": "bad domain"}) + + response = authed_client.post("/api/v1/onboarding/preview", json=payload) + + assert response.status_code == 422 + assert "invalid domain" in str(response.json()["detail"]) + + +def test_apply_onboarding_creates_workspace_assets_and_audit( + authed_client: TestClient, + db_session: Session, +): + """Applying a template creates the workspace, assets, defaults, and audit event.""" + response = authed_client.post( + "/api/v1/onboarding/apply", + json=_standard_payload(), + headers={"x-real-ip": "198.51.100.25"}, + ) + + assert response.status_code == 200 + result = response.json()["result"] + assert result["applied"] is True + assert result["workspace"]["slug"] == "client-one" + assert result["results"]["domains"][0]["status"] == "created" + assert result["results"]["mail_sources"][0]["status"] == "created" + assert "super-secret-password" not in str(result) + + workspace = db_session.query(Workspace).filter(Workspace.slug == "client-one").one() + domain = db_session.query(Domain).filter(Domain.name == "example.com").one() + source = db_session.query(MailSource).filter(MailSource.name == "Client One DMARC inbox").one() + setting = ( + db_session.query(Setting) + .filter(Setting.key == "notifications.alert_missing_reports_enabled") + .one() + ) + audit = ( + db_session.query(WorkspaceAuditLog) + .filter(WorkspaceAuditLog.action == "workspace.onboarding_applied") + .one() + ) + + assert domain.workspace_id == workspace.id + assert domain.dkim_selectors == "google,selector1" + assert source.workspace_id == workspace.id + assert source.server == "imap.example.com" + assert source.enabled is False + assert source.password == "super-secret-password" + assert setting.value == "true" + assert audit.workspace_id == workspace.id + assert audit.ip_address == "198.51.100.25" + assert "super-secret-password" not in (audit.details or "") + + +def test_apply_onboarding_is_idempotent_for_existing_assets( + authed_client: TestClient, + db_session: Session, +): + """Applying the same template again reports existing assets instead of duplicating them.""" + first = authed_client.post("/api/v1/onboarding/apply", json=_standard_payload()) + second = authed_client.post("/api/v1/onboarding/apply", json=_standard_payload()) + + assert first.status_code == 200 + assert second.status_code == 200 + result = second.json()["result"] + assert result["workspace"]["status"] == "existing" + assert result["results"]["domains"][0]["status"] == "existing" + assert result["results"]["mail_sources"][0]["status"] == "existing" + assert db_session.query(Workspace).count() == 1 + assert db_session.query(Domain).count() == 1 + assert db_session.query(MailSource).count() == 1 + + +def test_onboarding_notification_overwrite_is_explicit( + authed_client: TestClient, + db_session: Session, +): + """Existing notification settings are preserved unless overwrite_existing is true.""" + db_session.add( + Setting( + key="notifications.summary_weekly_enabled", + value="false", + description="Existing setting", + value_type="boolean", + category="notifications", + ) + ) + db_session.commit() + + response = authed_client.post("/api/v1/onboarding/apply", json=_standard_payload()) + assert response.status_code == 200 + row = ( + db_session.query(Setting) + .filter(Setting.key == "notifications.summary_weekly_enabled") + .one() + ) + assert row.value == "false" + + overwrite_payload = _standard_payload(overwrite_existing=True) + response = authed_client.post("/api/v1/onboarding/apply", json=overwrite_payload) + assert response.status_code == 200 + db_session.refresh(row) + assert row.value == "true" diff --git a/docs/milestones.md b/docs/milestones.md index 0815483..62763b4 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -258,7 +258,9 @@ Planned: definitions, workspace membership/audit tables, sanitized audit APIs, and audit records for sensitive API-token, mail-source, notification, webhook, and selector changes. -- Templates for onboarding new workspaces (domains + mail sources + notifications). +- Templates for onboarding new workspaces. Delivered in M15.3: versioned + workspace onboarding templates, preview/apply APIs, workspace/domain/mail + source seeding, notification defaults, and operator validation checklists. - Cross-workspace operator views for MSP admins, without weakening tenant isolation. Exit criteria: diff --git a/docs/reference/api.md b/docs/reference/api.md index 5554910..328dc81 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -121,6 +121,59 @@ filters: Audit details redact secret-like fields before they are stored. +### Workspace Onboarding + +Workspace onboarding endpoints require administrator access and render/apply +versioned onboarding templates for new client workspaces. + +#### List Onboarding Templates + +```text +GET /onboarding/templates +``` + +Returns template metadata, variables, domain and mail-source templates, +notification defaults, and operator checklist items. + +#### Preview Onboarding Plan + +```text +POST /onboarding/preview +``` + +Request: + +```json +{ + "template_id": "standard_monitoring", + "workspace": { + "slug": "client-one", + "name": "Client One" + }, + "variables": { + "domain": "example.com", + "report_mailbox": "dmarc@example.com", + "imap_server": "imap.example.com" + } +} +``` + +Returns the rendered plan without writing to the database. Secret-like fields +are redacted in the response. + +#### Apply Onboarding Plan + +```text +POST /onboarding/apply +``` + +Applies the rendered plan by creating or reusing the workspace, adding missing +domains and mail-source shells, seeding safe notification defaults, returning +the operator checklist, and writing a sanitized workspace audit event. + +Existing domains and mail sources are not duplicated. Existing notification +settings are preserved unless `overwrite_existing` is set to `true`. + ### Domains #### List Domains diff --git a/docs/reference/workspaces.md b/docs/reference/workspaces.md index a38f8a5..eff3119 100644 --- a/docs/reference/workspaces.md +++ b/docs/reference/workspaces.md @@ -74,6 +74,32 @@ legacy rows to the default workspace when needed. The RBAC/audit migration adds `workspace_memberships` for role assignments and `workspace_audit_logs` for workspace-scoped change history. +## Onboarding Templates + +Workspace onboarding templates give MSP operators a repeatable way to create a +new client workspace with minimal manual configuration. The template API can: + +- preview a rendered onboarding plan before anything is saved +- create or reuse a workspace +- seed monitored domains and manual DKIM selectors +- seed disabled mail-source shells for IMAP, Gmail, or Microsoft 365 +- seed safe notification defaults without storing notification target secrets +- return an operator checklist for DNS validation, mailbox connection, initial + import, and notification testing + +Available templates are exposed from `GET /api/v1/onboarding/templates`. +Operators render a plan with `POST /api/v1/onboarding/preview` and apply it +with `POST /api/v1/onboarding/apply`. + +The onboarding response and audit records redact secret-like fields, including +passwords, OAuth secrets, tokens, and API keys. Existing domains and mail +sources are treated idempotently; duplicate domains owned by another workspace +are rejected to keep ownership unambiguous. + +Notification defaults currently seed the existing notification settings table. +They intentionally avoid Apprise target URLs, so operators still add and test +delivery targets explicitly after onboarding. + The current implementation keeps domain names globally unique. That matches the existing single-domain ownership model and avoids ambiguous ownership while MSP RBAC and onboarding controls are built out.