feat: add workspace onboarding templates
This commit is contained in:
@@ -10,6 +10,7 @@ from app.api.api_v1.endpoints import (
|
|||||||
imap,
|
imap,
|
||||||
integrations,
|
integrations,
|
||||||
mail_sources,
|
mail_sources,
|
||||||
|
onboarding,
|
||||||
public,
|
public,
|
||||||
reports,
|
reports,
|
||||||
settings,
|
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(integrations.router, prefix="/integrations", tags=["integrations"])
|
||||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
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(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(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(tls_reports.router, prefix="/tls-reports", tags=["tls-reports"])
|
api_router.include_router(tls_reports.router, prefix="/tls-reports", tags=["tls-reports"])
|
||||||
api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"])
|
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
|
definitions, workspace membership/audit tables, sanitized audit APIs, and
|
||||||
audit records for sensitive API-token, mail-source, notification, webhook,
|
audit records for sensitive API-token, mail-source, notification, webhook,
|
||||||
and selector changes.
|
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.
|
- Cross-workspace operator views for MSP admins, without weakening tenant isolation.
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|||||||
@@ -121,6 +121,59 @@ filters:
|
|||||||
|
|
||||||
Audit details redact secret-like fields before they are stored.
|
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
|
### Domains
|
||||||
|
|
||||||
#### List 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
|
The RBAC/audit migration adds `workspace_memberships` for role assignments and
|
||||||
`workspace_audit_logs` for workspace-scoped change history.
|
`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
|
The current implementation keeps domain names globally unique. That matches the
|
||||||
existing single-domain ownership model and avoids ambiguous ownership while MSP
|
existing single-domain ownership model and avoids ambiguous ownership while MSP
|
||||||
RBAC and onboarding controls are built out.
|
RBAC and onboarding controls are built out.
|
||||||
|
|||||||
Reference in New Issue
Block a user