Merge pull request #197 from christianlouis/codex/m15-workspace-onboarding-templates
[codex] Add workspace onboarding templates
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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}
|
||||
@@ -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 '<empty>'}: {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", []),
|
||||
}
|
||||
)
|
||||
@@ -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"
|
||||
+3
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user