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.setting # noqa: E402, F401
import app.models.user # noqa: E402, F401 import app.models.user # noqa: E402, F401
import app.models.webhook # 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 # Import all models so that autogenerate can detect them
from app.core.database import Base # noqa: E402 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, hydrate_report_store_from_db,
) )
from app.services.report_store import ReportStore 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 from app.utils.domain_validator import validate_domain_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -417,22 +421,35 @@ def _normalize_domain_name(name: str) -> str:
return name.strip().strip(".").lower() 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() report_domains = store.get_domains()
stored_domains = [ stored_query = db.query(Domain.name).filter(Domain.active == True) # noqa: E712
name if workspace is not None:
for (name,) in db.query(Domain.name) stored_query = stored_query.filter(Domain.workspace_id == workspace.id)
.filter(Domain.active == True) # noqa: E712 stored_domains = [name for (name,) in stored_query.order_by(Domain.name).all()]
.order_by(Domain.name)
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() .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: def _domain_exists(db: Session, store: ReportStore, domain_name: str, workspace=None) -> bool:
return domain_name in store.get_domains() or bool( row = db.query(Domain.id, Domain.workspace_id).filter(Domain.name == domain_name).first()
db.query(Domain.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( def _record_evidence(
@@ -1070,10 +1087,12 @@ async def read_domains(db: Session = Depends(get_db)):
""" """
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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() summaries = store.get_all_domain_summaries()
stored = { 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 = [] result = []
@@ -1104,6 +1123,7 @@ async def create_domain(
_auth: dict = Depends(require_admin_auth), _auth: dict = Depends(require_admin_auth),
): ):
"""Create a monitored domain before any DMARC reports have arrived.""" """Create a monitored domain before any DMARC reports have arrived."""
workspace = assign_default_workspace_to_unscoped_rows(db)
name = _normalize_domain_name(payload.name) name = _normalize_domain_name(payload.name)
validation = validate_domain_config({"name": name, "description": payload.description or ""}) validation = validate_domain_config({"name": name, "description": payload.description or ""})
if not validation["valid"]: if not validation["valid"]:
@@ -1111,7 +1131,7 @@ async def create_domain(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=validation["errors"], 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: if existing:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
@@ -1124,6 +1144,7 @@ async def create_domain(
if selector and selector.strip() if selector and selector.strip()
) )
domain = Domain( domain = Domain(
workspace_id=workspace.id,
name=name, name=name,
description=payload.description, description=payload.description,
dkim_selectors=selectors or None, 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() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) hydrate_report_store_from_db(db, store)
domains = store.get_domains() 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: if domain_name not in domains and stored_domain is None:
raise HTTPException( raise HTTPException(
@@ -1230,8 +1252,9 @@ async def get_domain_dns_records(
""" """
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain 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.""" """Return evidence-linked DNS health and enforcement readiness guidance."""
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain 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.""" """Return an evidence-first posture dashboard for a monitored domain."""
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain 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.""" """Return cached MTA-STS DNS and HTTPS policy posture for a domain."""
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found", detail="Domain not found",
@@ -1345,7 +1371,8 @@ async def get_domain_bimi(
"""Return cached BIMI DNS posture for a domain.""" """Return cached BIMI DNS posture for a domain."""
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found", detail="Domain not found",
@@ -1461,8 +1488,9 @@ async def get_domain_reports(
""" """
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found", detail="Domain not found",
@@ -1513,8 +1541,9 @@ async def export_domain_reports(
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found", detail="Domain not found",
@@ -1827,8 +1856,9 @@ async def get_domain_sources(
""" """
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found", detail="Domain not found",
@@ -1884,7 +1914,8 @@ async def get_domain_selectors(
""" """
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found", detail="Domain not found",
@@ -1910,7 +1941,8 @@ async def add_domain_selector(
""" """
store = ReportStore.get_instance() store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store) 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found", detail="Domain not found",
@@ -1923,9 +1955,9 @@ async def add_domain_selector(
detail="Selector must not be empty", 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: if not domain_db:
domain_db = Domain(name=domain_id) domain_db = Domain(name=domain_id, workspace_id=workspace.id)
db.add(domain_db) db.add(domain_db)
existing = [s.strip() for s in (domain_db.dkim_selectors or "").split(",") if s.strip()] 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), db: Session = Depends(get_db),
): ):
"""Remove a manually configured DKIM selector from a domain.""" """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: if not domain_db:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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.setting # noqa: F401 ensure Setting table is registered
import app.models.user # noqa: F401 ensure User 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.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.api.api_v1.api import api_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.database import Base, SessionLocal, engine from app.core.database import Base, SessionLocal, engine
+4
View File
@@ -12,6 +12,7 @@ class Domain(Base):
__tablename__ = "domains" __tablename__ = "domains"
id = Column(Integer, primary_key=True, index=True) 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) name = Column(String, unique=True, index=True, nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
active = Column(Boolean, default=True, index=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) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships # Relationships
workspace = relationship("Workspace", back_populates="domains")
reports = relationship("DMARCReport", back_populates="domain", cascade="all, delete-orphan") reports = relationship("DMARCReport", back_populates="domain", cascade="all, delete-orphan")
forensic_reports = relationship( forensic_reports = relationship(
"ForensicReport", back_populates="domain", cascade="all, delete-orphan" "ForensicReport", back_populates="domain", cascade="all, delete-orphan"
@@ -41,6 +43,8 @@ class Domain(Base):
__table_args__ = ( __table_args__ = (
# Index for finding active and verified domains # Index for finding active and verified domains
Index("ix_domains_active_verified", "active", "verified"), 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 for finding domains by policy
Index("ix_domains_policy", "dmarc_policy"), Index("ix_domains_policy", "dmarc_policy"),
# Index for finding recently updated domains # Index for finding recently updated domains
+5 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime 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 sqlalchemy.orm import relationship
from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret
@@ -24,6 +24,7 @@ class MailSource(Base):
__tablename__ = "mail_sources" __tablename__ = "mail_sources"
id = Column(Integer, primary_key=True, index=True) 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 # Human-readable label for the source
name = Column(String, nullable=False) name = Column(String, nullable=False)
@@ -80,6 +81,9 @@ class MailSource(Base):
back_populates="mail_source", back_populates="mail_source",
cascade="all, delete-orphan", 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): def __repr__(self):
return f"<MailSource id={self.id} name={self.name!r} method={self.method!r}>" 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 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 sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -12,6 +12,7 @@ class User(Base):
__tablename__ = "users" __tablename__ = "users"
id = Column(Integer, primary_key=True, index=True) 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) email = Column(String, unique=True, index=True, nullable=False)
# Logto subject claim (the user's stable ID inside Logto). # Logto subject claim (the user's stable ID inside Logto).
# Null for users that pre-date Logto integration or for # Null for users that pre-date Logto integration or for
@@ -41,4 +42,5 @@ class User(Base):
) )
# Relationships # Relationships
workspace = relationship("Workspace", back_populates="users")
user_domains = relationship("UserDomain", back_populates="user", cascade="all, delete-orphan") 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.domain import Domain
from app.models.setting import Setting from app.models.setting import Setting
from app.services.dns_resolver import CloudflareDNSProvider, extract_dmarc_policy from app.services.dns_resolver import CloudflareDNSProvider, extract_dmarc_policy
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
PROVIDER_NAME = "cloudflare" PROVIDER_NAME = "cloudflare"
@@ -90,6 +91,7 @@ async def import_cloudflare_domains(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create Domain rows for Cloudflare zones, returning imported and existing names.""" """Create Domain rows for Cloudflare zones, returning imported and existing names."""
zones = await discover_cloudflare_zones(db) 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()} requested = {domain.strip().lower() for domain in requested_domains or [] if domain.strip()}
imported: List[str] = [] imported: List[str] = []
existing: List[str] = [] existing: List[str] = []
@@ -100,9 +102,13 @@ async def import_cloudflare_domains(
if requested and name not in requested: if requested and name not in requested:
skipped.append(name) skipped.append(name)
continue 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: 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) imported.append(name)
else: else:
existing.append(name) 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.domain import Domain
from app.models.report import ForensicReport from app.models.report import ForensicReport
from app.services.forensic_redaction import ForensicRedactionPolicy, redact_forensic_value 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 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: if not is_valid and error_code != DomainValidationError.DNS_RESOLUTION_FAILED:
return None 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: if domain is None:
domain = Domain(name=normalized) domain = Domain(name=normalized, workspace_id=workspace.id)
db.add(domain) db.add(domain)
db.flush() db.flush()
return domain 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.domain import Domain
from app.models.report import DMARCReport, ReportRecord from app.models.report import DMARCReport, ReportRecord
from app.services.report_store import ReportStore from app.services.report_store import ReportStore
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
def _parse_timestamp(value: Any) -> int: 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" domain_name = report.get("domain") or "unknown"
report_id = report.get("report_id") or "" report_id = report.get("report_id") or ""
policy = _policy_parts(report) 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: 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.add(domain)
db.flush() db.flush()
elif policy.get("p"): elif policy.get("p"):
@@ -12,9 +12,9 @@ from sqlalchemy.orm import Session, selectinload
from app.models.domain import Domain from app.models.domain import Domain
from app.models.report import TLSReport, TLSReportFailure 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 from app.utils.domain_validator import DomainValidationError, validate_domain
TLS_REPORT_PRIVACY_CONTROLS = { TLS_REPORT_PRIVACY_CONTROLS = {
"retention": ( "retention": (
"TLS reports store aggregate session counts, reporting organization metadata, " "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: if not is_valid and error_code != DomainValidationError.DNS_RESOLUTION_FAILED:
return None 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: if domain is None:
domain = Domain(name=normalized) domain = Domain(name=normalized, workspace_id=workspace.id)
db.add(domain) db.add(domain)
db.flush() db.flush()
return domain 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.setting # noqa: F401 # pylint: disable=unused-import
import app.models.user # 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.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.database import Base, get_db
from app.core.security import require_admin_auth from app.core.security import require_admin_auth
from app.main import create_app 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) ## 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. Goal: support multi-org deployments (e.g., MSPs) with strong isolation, ownership, and operator ergonomics.
Planned: 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. - Workspace-scoped RBAC and audit logs.
- Templates for onboarding new workspaces (domains + mail sources + notifications). - Templates for onboarding new workspaces (domains + mail sources + notifications).
- Cross-workspace operator views for MSP admins, without weakening tenant isolation. - 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 ## 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 ### Domains
The `domains` table stores information about the domains being monitored. 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 | | Column | Type | Description |
|--------|------|-------------| |--------|------|-------------|
| id | INTEGER | Primary key | | id | INTEGER | Primary key |
| workspace_id | INTEGER | Foreign key to workspaces.id |
| name | VARCHAR(255) | Domain name (e.g., example.com) | | name | VARCHAR(255) | Domain name (e.g., example.com) |
| created_at | TIMESTAMP | When the domain was added | | created_at | TIMESTAMP | When the domain was added |
| active | BOOLEAN | Whether the domain is actively monitored | | active | BOOLEAN | Whether the domain is actively monitored |
@@ -105,6 +122,7 @@ The `users` table stores user account information.
| Column | Type | Description | | Column | Type | Description |
|--------|------|-------------| |--------|------|-------------|
| id | INTEGER | Primary key | | id | INTEGER | Primary key |
| workspace_id | INTEGER | Foreign key to workspaces.id |
| username | VARCHAR(50) | Username | | username | VARCHAR(50) | Username |
| email | VARCHAR(255) | Email address | | email | VARCHAR(255) | Email address |
| password_hash | VARCHAR(255) | Hashed password | | password_hash | VARCHAR(255) | Hashed password |
@@ -177,6 +195,26 @@ The `webhook_deliveries` table records delivery attempts and retry state.
## DNS and Configuration Tables ## 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 ### DNS_Records
The `dns_records` table stores DNS record information for domains. 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 - API Reference: reference/api.md
- SIEM Integrations: reference/siem-integrations.md - SIEM Integrations: reference/siem-integrations.md
- Ticketing and Chatops: reference/ticketing-chatops-integrations.md - Ticketing and Chatops: reference/ticketing-chatops-integrations.md
- Workspaces: reference/workspaces.md
- Architecture: reference/architecture.md - Architecture: reference/architecture.md
- Database Schema: reference/database.md - Database Schema: reference/database.md
- Development: - Development: