feat: add workspace tenant foundations

This commit is contained in:
Christian Krakau-Louis
2026-05-23 19:05:55 +02:00
parent 60193c990b
commit b503d9c9fe
19 changed files with 576 additions and 43 deletions
+1
View File
@@ -30,6 +30,7 @@ import app.models.report # noqa: E402, F401
import app.models.setting # noqa: E402, F401
import app.models.user # noqa: E402, F401
import app.models.webhook # noqa: E402, F401
import app.models.workspace # noqa: E402, F401
# Import all models so that autogenerate can detect them
from app.core.database import Base # noqa: E402
@@ -0,0 +1,129 @@
"""add workspace foundations
Revision ID: 2c3d4e5f6a7b
Revises: 1b2c3d4e5f6a
Create Date: 2026-05-23 18:58:00.000000
"""
from datetime import datetime
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "2c3d4e5f6a7b"
down_revision = "1b2c3d4e5f6a"
branch_labels = None
depends_on = None
def _default_workspace_id_sql() -> str:
return "(SELECT id FROM workspaces WHERE slug = 'default')"
def upgrade() -> None:
"""Create workspaces and attach existing single-tenant rows to default."""
op.create_table(
"workspaces",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("slug", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_index(op.f("ix_workspaces_id"), "workspaces", ["id"])
op.create_index(op.f("ix_workspaces_slug"), "workspaces", ["slug"])
op.create_index(op.f("ix_workspaces_active"), "workspaces", ["active"])
op.create_index(op.f("ix_workspaces_created_at"), "workspaces", ["created_at"])
op.create_index("ix_workspaces_active_slug", "workspaces", ["active", "slug"])
now = datetime.utcnow()
workspaces = sa.table(
"workspaces",
sa.column("slug", sa.String()),
sa.column("name", sa.String()),
sa.column("description", sa.Text()),
sa.column("active", sa.Boolean()),
sa.column("created_at", sa.DateTime()),
sa.column("updated_at", sa.DateTime()),
)
op.bulk_insert(
workspaces,
[
{
"slug": "default",
"name": "Default Workspace",
"description": "Automatically created for existing single-tenant installs.",
"active": True,
"created_at": now,
"updated_at": now,
}
],
)
with op.batch_alter_table("domains") as batch_op:
batch_op.add_column(sa.Column("workspace_id", sa.Integer(), nullable=True))
batch_op.create_foreign_key(
"fk_domains_workspace_id_workspaces",
"workspaces",
["workspace_id"],
["id"],
)
batch_op.create_index(op.f("ix_domains_workspace_id"), ["workspace_id"])
batch_op.create_index("ix_domains_workspace_name", ["workspace_id", "name"])
with op.batch_alter_table("mail_sources") as batch_op:
batch_op.add_column(sa.Column("workspace_id", sa.Integer(), nullable=True))
batch_op.create_foreign_key(
"fk_mail_sources_workspace_id_workspaces",
"workspaces",
["workspace_id"],
["id"],
)
batch_op.create_index(op.f("ix_mail_sources_workspace_id"), ["workspace_id"])
batch_op.create_index("ix_mail_sources_workspace_enabled", ["workspace_id", "enabled"])
with op.batch_alter_table("users") as batch_op:
batch_op.add_column(sa.Column("workspace_id", sa.Integer(), nullable=True))
batch_op.create_foreign_key(
"fk_users_workspace_id_workspaces",
"workspaces",
["workspace_id"],
["id"],
)
batch_op.create_index(op.f("ix_users_workspace_id"), ["workspace_id"])
op.execute(f"UPDATE domains SET workspace_id = {_default_workspace_id_sql()}")
op.execute(f"UPDATE mail_sources SET workspace_id = {_default_workspace_id_sql()}")
op.execute(f"UPDATE users SET workspace_id = {_default_workspace_id_sql()}")
def downgrade() -> None:
"""Remove workspace ownership columns and table."""
with op.batch_alter_table("users") as batch_op:
batch_op.drop_index(op.f("ix_users_workspace_id"))
batch_op.drop_constraint("fk_users_workspace_id_workspaces", type_="foreignkey")
batch_op.drop_column("workspace_id")
with op.batch_alter_table("mail_sources") as batch_op:
batch_op.drop_index("ix_mail_sources_workspace_enabled")
batch_op.drop_index(op.f("ix_mail_sources_workspace_id"))
batch_op.drop_constraint("fk_mail_sources_workspace_id_workspaces", type_="foreignkey")
batch_op.drop_column("workspace_id")
with op.batch_alter_table("domains") as batch_op:
batch_op.drop_index("ix_domains_workspace_name")
batch_op.drop_index(op.f("ix_domains_workspace_id"))
batch_op.drop_constraint("fk_domains_workspace_id_workspaces", type_="foreignkey")
batch_op.drop_column("workspace_id")
op.drop_index("ix_workspaces_active_slug", table_name="workspaces")
op.drop_index(op.f("ix_workspaces_created_at"), table_name="workspaces")
op.drop_index(op.f("ix_workspaces_active"), table_name="workspaces")
op.drop_index(op.f("ix_workspaces_slug"), table_name="workspaces")
op.drop_index(op.f("ix_workspaces_id"), table_name="workspaces")
op.drop_table("workspaces")
+61 -28
View File
@@ -36,6 +36,10 @@ from app.services.report_persistence import (
hydrate_report_store_from_db,
)
from app.services.report_store import ReportStore
from app.services.workspaces import (
assign_default_workspace_to_unscoped_rows,
workspace_domain_query,
)
from app.utils.domain_validator import validate_domain_config
logger = logging.getLogger(__name__)
@@ -417,22 +421,35 @@ def _normalize_domain_name(name: str) -> str:
return name.strip().strip(".").lower()
def _domain_names_for_summary(db: Session, store: ReportStore) -> List[str]:
def _domain_names_for_summary(db: Session, store: ReportStore, workspace=None) -> List[str]:
report_domains = store.get_domains()
stored_domains = [
name
for (name,) in db.query(Domain.name)
.filter(Domain.active == True) # noqa: E712
.order_by(Domain.name)
stored_query = db.query(Domain.name).filter(Domain.active == True) # noqa: E712
if workspace is not None:
stored_query = stored_query.filter(Domain.workspace_id == workspace.id)
stored_domains = [name for (name,) in stored_query.order_by(Domain.name).all()]
if workspace is None:
scoped_report_domains = report_domains
else:
stored_scope = {
name: workspace_id
for name, workspace_id in db.query(Domain.name, Domain.workspace_id)
.filter(Domain.name.in_(report_domains))
.all()
}
scoped_report_domains = [
name
for name in report_domains
if name not in stored_scope or stored_scope[name] == workspace.id
]
return list(dict.fromkeys(stored_domains + report_domains))
return list(dict.fromkeys(stored_domains + scoped_report_domains))
def _domain_exists(db: Session, store: ReportStore, domain_name: str) -> bool:
return domain_name in store.get_domains() or bool(
db.query(Domain.id).filter(Domain.name == domain_name).first()
)
def _domain_exists(db: Session, store: ReportStore, domain_name: str, workspace=None) -> bool:
row = db.query(Domain.id, Domain.workspace_id).filter(Domain.name == domain_name).first()
if row:
return workspace is None or row.workspace_id == workspace.id
return domain_name in store.get_domains()
def _record_evidence(
@@ -1070,10 +1087,12 @@ async def read_domains(db: Session = Depends(get_db)):
"""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
domains = _domain_names_for_summary(db, store)
workspace = assign_default_workspace_to_unscoped_rows(db)
domains = _domain_names_for_summary(db, store, workspace)
summaries = store.get_all_domain_summaries()
stored = {
domain.name: domain for domain in db.query(Domain).filter(Domain.name.in_(domains)).all()
domain.name: domain
for domain in workspace_domain_query(db, workspace).filter(Domain.name.in_(domains)).all()
}
result = []
@@ -1104,6 +1123,7 @@ async def create_domain(
_auth: dict = Depends(require_admin_auth),
):
"""Create a monitored domain before any DMARC reports have arrived."""
workspace = assign_default_workspace_to_unscoped_rows(db)
name = _normalize_domain_name(payload.name)
validation = validate_domain_config({"name": name, "description": payload.description or ""})
if not validation["valid"]:
@@ -1111,7 +1131,7 @@ async def create_domain(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=validation["errors"],
)
existing = db.query(Domain).filter(Domain.name == name).first()
existing = workspace_domain_query(db, workspace).filter(Domain.name == name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
@@ -1124,6 +1144,7 @@ async def create_domain(
if selector and selector.strip()
)
domain = Domain(
workspace_id=workspace.id,
name=name,
description=payload.description,
dkim_selectors=selectors or None,
@@ -1155,7 +1176,8 @@ async def read_domain(domain_name: str, db: Session = Depends(get_db)):
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
domains = store.get_domains()
stored_domain = db.query(Domain).filter(Domain.name == domain_name).first()
workspace = assign_default_workspace_to_unscoped_rows(db)
stored_domain = workspace_domain_query(db, workspace).filter(Domain.name == domain_name).first()
if domain_name not in domains and stored_domain is None:
raise HTTPException(
@@ -1230,8 +1252,9 @@ async def get_domain_dns_records(
"""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id):
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1271,7 +1294,8 @@ async def get_domain_dns_health(
"""Return evidence-linked DNS health and enforcement readiness guidance."""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
if not _domain_exists(db, store, domain_id):
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1289,7 +1313,8 @@ async def get_domain_posture_dashboard(
"""Return an evidence-first posture dashboard for a monitored domain."""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
if not _domain_exists(db, store, domain_id):
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1309,7 +1334,8 @@ async def get_domain_mta_sts(
"""Return cached MTA-STS DNS and HTTPS policy posture for a domain."""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
if not _domain_exists(db, store, domain_id):
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1345,7 +1371,8 @@ async def get_domain_bimi(
"""Return cached BIMI DNS posture for a domain."""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
if not _domain_exists(db, store, domain_id):
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1461,8 +1488,9 @@ async def get_domain_reports(
"""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id):
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1513,8 +1541,9 @@ async def export_domain_reports(
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id):
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1827,8 +1856,9 @@ async def get_domain_sources(
"""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id):
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1884,7 +1914,8 @@ async def get_domain_selectors(
"""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
if not _domain_exists(db, store, domain_id):
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1910,7 +1941,8 @@ async def add_domain_selector(
"""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
if domain_id not in store.get_domains():
workspace = assign_default_workspace_to_unscoped_rows(db)
if not _domain_exists(db, store, domain_id, workspace):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1923,9 +1955,9 @@ async def add_domain_selector(
detail="Selector must not be empty",
)
domain_db = db.query(Domain).filter(Domain.name == domain_id).first()
domain_db = workspace_domain_query(db, workspace).filter(Domain.name == domain_id).first()
if not domain_db:
domain_db = Domain(name=domain_id)
domain_db = Domain(name=domain_id, workspace_id=workspace.id)
db.add(domain_db)
existing = [s.strip() for s in (domain_db.dkim_selectors or "").split(",") if s.strip()]
@@ -1944,7 +1976,8 @@ async def delete_domain_selector(
db: Session = Depends(get_db),
):
"""Remove a manually configured DKIM selector from a domain."""
domain_db = db.query(Domain).filter(Domain.name == domain_id).first()
workspace = assign_default_workspace_to_unscoped_rows(db)
domain_db = workspace_domain_query(db, workspace).filter(Domain.name == domain_id).first()
if not domain_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+1
View File
@@ -20,6 +20,7 @@ import app.models.report # noqa: F401 ensure DMARCReport/ReportRecord table
import app.models.setting # noqa: F401 ensure Setting table is registered
import app.models.user # noqa: F401 ensure User table is registered
import app.models.webhook # noqa: F401 ensure webhook tables are registered
import app.models.workspace # noqa: F401 ensure workspace table is registered
from app.api.api_v1.api import api_router
from app.core.config import get_settings
from app.core.database import Base, SessionLocal, engine
+4
View File
@@ -12,6 +12,7 @@ class Domain(Base):
__tablename__ = "domains"
id = Column(Integer, primary_key=True, index=True)
workspace_id = Column(Integer, ForeignKey("workspaces.id"), nullable=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
active = Column(Boolean, default=True, index=True)
@@ -30,6 +31,7 @@ class Domain(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
workspace = relationship("Workspace", back_populates="domains")
reports = relationship("DMARCReport", back_populates="domain", cascade="all, delete-orphan")
forensic_reports = relationship(
"ForensicReport", back_populates="domain", cascade="all, delete-orphan"
@@ -41,6 +43,8 @@ class Domain(Base):
__table_args__ = (
# Index for finding active and verified domains
Index("ix_domains_active_verified", "active", "verified"),
# Workspace-scoped domain lookups for MSP mode.
Index("ix_domains_workspace_name", "workspace_id", "name"),
# Index for finding domains by policy
Index("ix_domains_policy", "dmarc_policy"),
# Index for finding recently updated domains
+5 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret
@@ -24,6 +24,7 @@ class MailSource(Base):
__tablename__ = "mail_sources"
id = Column(Integer, primary_key=True, index=True)
workspace_id = Column(Integer, ForeignKey("workspaces.id"), nullable=True, index=True)
# Human-readable label for the source
name = Column(String, nullable=False)
@@ -80,6 +81,9 @@ class MailSource(Base):
back_populates="mail_source",
cascade="all, delete-orphan",
)
workspace = relationship("Workspace", back_populates="mail_sources")
__table_args__ = (Index("ix_mail_sources_workspace_enabled", "workspace_id", "enabled"),)
def __repr__(self):
return f"<MailSource id={self.id} name={self.name!r} method={self.method!r}>"
+3 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -12,6 +12,7 @@ class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
workspace_id = Column(Integer, ForeignKey("workspaces.id"), nullable=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
# Logto subject claim (the user's stable ID inside Logto).
# Null for users that pre-date Logto integration or for
@@ -41,4 +42,5 @@ class User(Base):
)
# Relationships
workspace = relationship("Workspace", back_populates="users")
user_domains = relationship("UserDomain", back_populates="user", cascade="all, delete-orphan")
+29
View File
@@ -0,0 +1,29 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
class Workspace(Base):
"""Tenant/workspace boundary for monitored DMARC assets."""
__tablename__ = "workspaces"
id = Column(Integer, primary_key=True, index=True)
slug = Column(String, unique=True, nullable=False, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
active = Column(Boolean, default=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
domains = relationship("Domain", back_populates="workspace")
mail_sources = relationship("MailSource", back_populates="workspace")
users = relationship("User", back_populates="workspace")
__table_args__ = (Index("ix_workspaces_active_slug", "active", "slug"),)
def __repr__(self):
return f"<Workspace {self.slug}>"
+8 -2
View File
@@ -16,6 +16,7 @@ from app.models.dns_cache import DNSRecordChange, DNSRecordSnapshot
from app.models.domain import Domain
from app.models.setting import Setting
from app.services.dns_resolver import CloudflareDNSProvider, extract_dmarc_policy
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
PROVIDER_NAME = "cloudflare"
@@ -90,6 +91,7 @@ async def import_cloudflare_domains(
) -> Dict[str, Any]:
"""Create Domain rows for Cloudflare zones, returning imported and existing names."""
zones = await discover_cloudflare_zones(db)
workspace = assign_default_workspace_to_unscoped_rows(db)
requested = {domain.strip().lower() for domain in requested_domains or [] if domain.strip()}
imported: List[str] = []
existing: List[str] = []
@@ -100,9 +102,13 @@ async def import_cloudflare_domains(
if requested and name not in requested:
skipped.append(name)
continue
domain = db.query(Domain).filter(Domain.name == name).first()
domain = (
db.query(Domain)
.filter(Domain.name == name, Domain.workspace_id == workspace.id)
.first()
)
if domain is None:
db.add(Domain(name=name, active=True, verified=True))
db.add(Domain(name=name, active=True, verified=True, workspace_id=workspace.id))
imported.append(name)
else:
existing.append(name)
+8 -2
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import ForensicReport
from app.services.forensic_redaction import ForensicRedactionPolicy, redact_forensic_value
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
from app.utils.domain_validator import DomainValidationError, validate_domain
@@ -28,9 +29,14 @@ def _domain_for_report(db: Session, domain_name: Optional[str]) -> Optional[Doma
if not is_valid and error_code != DomainValidationError.DNS_RESOLUTION_FAILED:
return None
domain = db.query(Domain).filter(Domain.name == normalized).first()
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
domain = (
db.query(Domain)
.filter(Domain.name == normalized, Domain.workspace_id == workspace.id)
.first()
)
if domain is None:
domain = Domain(name=normalized)
domain = Domain(name=normalized, workspace_id=workspace.id)
db.add(domain)
db.flush()
return domain
+8 -2
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session, selectinload
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.services.report_store import ReportStore
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
def _parse_timestamp(value: Any) -> int:
@@ -98,10 +99,15 @@ def save_parsed_report(db: Session, report: Dict[str, Any]) -> tuple[DMARCReport
domain_name = report.get("domain") or "unknown"
report_id = report.get("report_id") or ""
policy = _policy_parts(report)
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
domain = db.query(Domain).filter(Domain.name == domain_name).first()
domain = (
db.query(Domain)
.filter(Domain.name == domain_name, Domain.workspace_id == workspace.id)
.first()
)
if domain is None:
domain = Domain(name=domain_name, dmarc_policy=policy["p"])
domain = Domain(name=domain_name, dmarc_policy=policy["p"], workspace_id=workspace.id)
db.add(domain)
db.flush()
elif policy.get("p"):
@@ -12,9 +12,9 @@ from sqlalchemy.orm import Session, selectinload
from app.models.domain import Domain
from app.models.report import TLSReport, TLSReportFailure
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
from app.utils.domain_validator import DomainValidationError, validate_domain
TLS_REPORT_PRIVACY_CONTROLS = {
"retention": (
"TLS reports store aggregate session counts, reporting organization metadata, "
@@ -69,9 +69,14 @@ def _domain_for_report(db: Session, domain_name: Optional[str]) -> Optional[Doma
if not is_valid and error_code != DomainValidationError.DNS_RESOLUTION_FAILED:
return None
domain = db.query(Domain).filter(Domain.name == normalized).first()
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
domain = (
db.query(Domain)
.filter(Domain.name == normalized, Domain.workspace_id == workspace.id)
.first()
)
if domain is None:
domain = Domain(name=normalized)
domain = Domain(name=normalized, workspace_id=workspace.id)
db.add(domain)
db.flush()
return domain
+116
View File
@@ -0,0 +1,116 @@
"""Workspace/tenant helpers for MSP mode foundations."""
from __future__ import annotations
from typing import Optional
from sqlalchemy.orm import Query, Session
from app.models.domain import Domain
from app.models.mail_source import MailSource
from app.models.user import User
from app.models.workspace import Workspace
DEFAULT_WORKSPACE_SLUG = "default"
DEFAULT_WORKSPACE_NAME = "Default Workspace"
def normalize_workspace_slug(value: str) -> str:
"""Normalize a workspace slug for stable lookups."""
slug = (value or "").strip().lower()
cleaned = []
previous_dash = False
for char in slug:
if char.isalnum():
cleaned.append(char)
previous_dash = False
elif not previous_dash:
cleaned.append("-")
previous_dash = True
return "".join(cleaned).strip("-")
def get_or_create_default_workspace(db: Session, *, commit: bool = True) -> Workspace:
"""Return the single-tenant default workspace, creating it when needed."""
workspace = db.query(Workspace).filter(Workspace.slug == DEFAULT_WORKSPACE_SLUG).first()
if workspace:
return workspace
workspace = Workspace(
slug=DEFAULT_WORKSPACE_SLUG,
name=DEFAULT_WORKSPACE_NAME,
description="Automatically created for existing single-tenant installs.",
active=True,
)
db.add(workspace)
if commit:
db.commit()
db.refresh(workspace)
else:
db.flush()
return workspace
def assign_default_workspace_to_unscoped_rows(
db: Session,
*,
commit: bool = True,
) -> Workspace:
"""Attach legacy unscoped rows to the default workspace."""
workspace = get_or_create_default_workspace(db, commit=commit)
for model in (Domain, MailSource, User):
db.query(model).filter(model.workspace_id.is_(None)).update(
{model.workspace_id: workspace.id},
synchronize_session=False,
)
if commit:
db.commit()
else:
db.flush()
return workspace
def resolve_workspace(
db: Session,
*,
workspace_id: Optional[int] = None,
slug: Optional[str] = None,
) -> Workspace:
"""Resolve a workspace, defaulting to the single-tenant workspace."""
if workspace_id is not None:
workspace = (
db.query(Workspace)
.filter(Workspace.id == workspace_id, Workspace.active.is_(True))
.first()
)
if workspace:
return workspace
raise ValueError("Workspace not found")
if slug:
normalized = normalize_workspace_slug(slug)
workspace = (
db.query(Workspace)
.filter(Workspace.slug == normalized, Workspace.active.is_(True))
.first()
)
if workspace:
return workspace
raise ValueError("Workspace not found")
return assign_default_workspace_to_unscoped_rows(db)
def workspace_domain_query(db: Session, workspace: Workspace) -> Query:
"""Return the default scoped domain query for a workspace."""
return db.query(Domain).filter(Domain.workspace_id == workspace.id)
def workspace_mail_source_query(db: Session, workspace: Workspace) -> Query:
"""Return the default scoped mail-source query for a workspace."""
return db.query(MailSource).filter(MailSource.workspace_id == workspace.id)
def workspace_user_query(db: Session, workspace: Workspace) -> Query:
"""Return the default scoped user query for a workspace."""
return db.query(User).filter(User.workspace_id == workspace.id)
+1
View File
@@ -16,6 +16,7 @@ import app.models.report # noqa: F401 # pylint: disable=unused-import
import app.models.setting # noqa: F401 # pylint: disable=unused-import
import app.models.user # noqa: F401 # pylint: disable=unused-import
import app.models.webhook # noqa: F401 # pylint: disable=unused-import
import app.models.workspace # noqa: F401 # pylint: disable=unused-import
from app.core.database import Base, get_db
from app.core.security import require_admin_auth
from app.main import create_app
+107
View File
@@ -0,0 +1,107 @@
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.user import User
from app.models.workspace import Workspace
from app.services.workspaces import (
DEFAULT_WORKSPACE_SLUG,
assign_default_workspace_to_unscoped_rows,
get_or_create_default_workspace,
normalize_workspace_slug,
resolve_workspace,
workspace_domain_query,
workspace_mail_source_query,
workspace_user_query,
)
def test_default_workspace_claims_legacy_rows(db_session: Session):
"""Existing single-tenant rows are attached to the default workspace."""
domain = Domain(name="legacy.example", active=True)
source = MailSource(name="Legacy inbox", method="IMAP", enabled=True)
user = User(email="operator@example.com")
db_session.add_all([domain, source, user])
db_session.commit()
workspace = assign_default_workspace_to_unscoped_rows(db_session)
db_session.refresh(domain)
db_session.refresh(source)
db_session.refresh(user)
assert workspace.slug == DEFAULT_WORKSPACE_SLUG
assert domain.workspace_id == workspace.id
assert source.workspace_id == workspace.id
assert user.workspace_id == workspace.id
def test_workspace_scoped_queries_exclude_other_tenants(db_session: Session):
"""Default scoped queries only return rows owned by that workspace."""
default = get_or_create_default_workspace(db_session)
other = Workspace(slug="client-two", name="Client Two", active=True)
db_session.add(other)
db_session.flush()
db_session.add_all(
[
Domain(name="default.example", workspace_id=default.id, active=True),
Domain(name="client-two.example", workspace_id=other.id, active=True),
MailSource(name="default inbox", method="IMAP", workspace_id=default.id),
MailSource(name="client two inbox", method="IMAP", workspace_id=other.id),
User(email="default@example.com", workspace_id=default.id),
User(email="client-two@example.com", workspace_id=other.id),
]
)
db_session.commit()
assert [row.name for row in workspace_domain_query(db_session, default).all()] == [
"default.example"
]
assert [row.name for row in workspace_mail_source_query(db_session, default).all()] == [
"default inbox"
]
assert [row.email for row in workspace_user_query(db_session, default).all()] == [
"default@example.com"
]
def test_workspace_resolution_and_slug_validation(db_session: Session):
"""Workspace lookup supports default, id, and normalized slug paths."""
default = resolve_workspace(db_session)
other = Workspace(slug="client-two", name="Client Two", active=True)
db_session.add(other)
db_session.commit()
db_session.refresh(other)
assert normalize_workspace_slug(" Client Two!! ") == "client-two"
assert resolve_workspace(db_session).id == default.id
assert resolve_workspace(db_session, workspace_id=other.id).id == other.id
assert resolve_workspace(db_session, slug="Client Two").id == other.id
def test_domain_api_defaults_to_default_workspace(
authed_client: TestClient,
db_session: Session,
):
"""Domain API creates and lists rows inside the default workspace boundary."""
other = Workspace(slug="client-two", name="Client Two", active=True)
db_session.add(other)
db_session.flush()
db_session.add(Domain(name="other.example", workspace_id=other.id, active=True))
db_session.commit()
created = authed_client.post(
"/api/v1/domains/domains",
json={"name": "Default.Example", "description": "Default tenant"},
)
assert created.status_code == 201
workspace = get_or_create_default_workspace(db_session)
domain = db_session.query(Domain).filter(Domain.name == "default.example").first()
assert domain.workspace_id == workspace.id
listed = authed_client.get("/api/v1/domains/domains")
assert listed.status_code == 200
names = {item["name"] for item in listed.json()}
assert "default.example" in names
assert "other.example" not in names
+4 -2
View File
@@ -246,12 +246,14 @@ Exit criteria:
## Milestone 15: Workspaces / MSP Mode (Multi-Org Governance)
Status: Backlog
Status: In progress
Goal: support multi-org deployments (e.g., MSPs) with strong isolation, ownership, and operator ergonomics.
Planned:
- Workspace/tenant concept with clear domain ownership.
- Workspace/tenant concept with clear domain ownership. Delivered in M15.1:
default workspace migration, ownership columns for domains/users/mail sources,
and scoped query helpers.
- Workspace-scoped RBAC and audit logs.
- Templates for onboarding new workspaces (domains + mail sources + notifications).
- Cross-workspace operator views for MSP admins, without weakening tenant isolation.
+38
View File
@@ -12,6 +12,22 @@ DMARQ uses a relational database to store all its data. The schema is designed t
## Core Tables
### Workspaces
The `workspaces` table stores tenant boundaries for multi-organization and MSP
deployments. Existing single-tenant installs are attached to the default
workspace during migration.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| slug | VARCHAR | Unique stable workspace slug |
| name | VARCHAR | Display name |
| description | TEXT | Optional operator-facing description |
| active | BOOLEAN | Whether the workspace can be used |
| created_at | TIMESTAMP | When the workspace was created |
| updated_at | TIMESTAMP | When the workspace was last updated |
### Domains
The `domains` table stores information about the domains being monitored.
@@ -19,6 +35,7 @@ The `domains` table stores information about the domains being monitored.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| workspace_id | INTEGER | Foreign key to workspaces.id |
| name | VARCHAR(255) | Domain name (e.g., example.com) |
| created_at | TIMESTAMP | When the domain was added |
| active | BOOLEAN | Whether the domain is actively monitored |
@@ -105,6 +122,7 @@ The `users` table stores user account information.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| workspace_id | INTEGER | Foreign key to workspaces.id |
| username | VARCHAR(50) | Username |
| email | VARCHAR(255) | Email address |
| password_hash | VARCHAR(255) | Hashed password |
@@ -177,6 +195,26 @@ The `webhook_deliveries` table records delivery attempts and retry state.
## DNS and Configuration Tables
### Mail_Sources
The `mail_sources` table stores configured inboxes used to retrieve DMARC,
forensic, and SMTP TLS reports.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| workspace_id | INTEGER | Foreign key to workspaces.id |
| name | VARCHAR | Human-readable source name |
| method | VARCHAR | Source type such as IMAP, Gmail API, or Microsoft Graph |
| server | VARCHAR | IMAP/POP server hostname |
| port | INTEGER | IMAP/POP server port |
| username | VARCHAR | Mailbox username |
| password | TEXT | Encrypted mailbox password |
| enabled | BOOLEAN | Whether scheduled imports should poll this source |
| last_checked | TIMESTAMP | Last polling attempt time |
| created_at | TIMESTAMP | When the source was created |
| updated_at | TIMESTAMP | When the source was last updated |
### DNS_Records
The `dns_records` table stores DNS record information for domains.
+42
View File
@@ -0,0 +1,42 @@
# Workspace Foundations
DMARQ uses a workspace as the tenant boundary for multi-organization and MSP
deployments. Milestone 15 starts with a default single-tenant workspace so
existing deployments have a clear migration path before workspace RBAC and MSP
views are added.
## Default Workspace
Existing installs are migrated into:
```text
slug: default
name: Default Workspace
```
The migration attaches legacy domains, users, and mail sources to this default
workspace. New domain rows created by the API also default to this workspace.
## Ownership Boundaries
The initial workspace model relates these core records to a workspace:
- monitored domains
- mail sources
- users
Domain, mail-source, and user query helpers scope reads to a workspace by
default. This prevents cross-tenant reads in new M15 surfaces and gives later
RBAC work a single ownership field to enforce.
## Migration Story
The migration creates the `workspaces` table, inserts the default workspace, and
adds nullable `workspace_id` ownership columns to existing domain, user, and
mail-source tables. Nullable columns keep upgrades safe for older databases and
for import paths that may be backfilled in stages; runtime helpers attach
legacy rows to the default workspace when needed.
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.
+1
View File
@@ -42,6 +42,7 @@ nav:
- API Reference: reference/api.md
- SIEM Integrations: reference/siem-integrations.md
- Ticketing and Chatops: reference/ticketing-chatops-integrations.md
- Workspaces: reference/workspaces.md
- Architecture: reference/architecture.md
- Database Schema: reference/database.md
- Development: