From b503d9c9fec67befdcba48d12eaf541a23b50fb0 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 23 May 2026 19:05:55 +0200 Subject: [PATCH] feat: add workspace tenant foundations --- backend/alembic/env.py | 1 + .../2c3d4e5f6a7b_add_workspace_foundations.py | 129 ++++++++++++++++++ backend/app/api/api_v1/endpoints/domains.py | 93 +++++++++---- backend/app/main.py | 1 + backend/app/models/domain.py | 4 + backend/app/models/mail_source.py | 6 +- backend/app/models/user.py | 4 +- backend/app/models/workspace.py | 29 ++++ backend/app/services/cloudflare_dns.py | 10 +- backend/app/services/forensic_persistence.py | 10 +- backend/app/services/report_persistence.py | 10 +- .../app/services/tls_report_persistence.py | 11 +- backend/app/services/workspaces.py | 116 ++++++++++++++++ backend/app/tests/conftest.py | 1 + backend/app/tests/test_workspaces.py | 107 +++++++++++++++ docs/milestones.md | 6 +- docs/reference/database.md | 38 ++++++ docs/reference/workspaces.md | 42 ++++++ mkdocs.yml | 1 + 19 files changed, 576 insertions(+), 43 deletions(-) create mode 100644 backend/alembic/versions/2c3d4e5f6a7b_add_workspace_foundations.py create mode 100644 backend/app/models/workspace.py create mode 100644 backend/app/services/workspaces.py create mode 100644 backend/app/tests/test_workspaces.py create mode 100644 docs/reference/workspaces.md diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 1aa3456..0975c56 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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 diff --git a/backend/alembic/versions/2c3d4e5f6a7b_add_workspace_foundations.py b/backend/alembic/versions/2c3d4e5f6a7b_add_workspace_foundations.py new file mode 100644 index 0000000..d2adb92 --- /dev/null +++ b/backend/alembic/versions/2c3d4e5f6a7b_add_workspace_foundations.py @@ -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") diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py index 563d971..4e25e97 100644 --- a/backend/app/api/api_v1/endpoints/domains.py +++ b/backend/app/api/api_v1/endpoints/domains.py @@ -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) - .all() - ] - return list(dict.fromkeys(stored_domains + report_domains)) + 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 + 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, diff --git a/backend/app/main.py b/backend/app/main.py index f92f727..fa9e170 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index efe7085..d9bd093 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -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 diff --git a/backend/app/models/mail_source.py b/backend/app/models/mail_source.py index 440ed74..ded3fbb 100644 --- a/backend/app/models/mail_source.py +++ b/backend/app/models/mail_source.py @@ -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"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index cc60640..abeaf98 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py new file mode 100644 index 0000000..12e41fe --- /dev/null +++ b/backend/app/models/workspace.py @@ -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"" diff --git a/backend/app/services/cloudflare_dns.py b/backend/app/services/cloudflare_dns.py index b7e0465..9119665 100644 --- a/backend/app/services/cloudflare_dns.py +++ b/backend/app/services/cloudflare_dns.py @@ -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) diff --git a/backend/app/services/forensic_persistence.py b/backend/app/services/forensic_persistence.py index e8fecf6..ef0a6d4 100644 --- a/backend/app/services/forensic_persistence.py +++ b/backend/app/services/forensic_persistence.py @@ -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 diff --git a/backend/app/services/report_persistence.py b/backend/app/services/report_persistence.py index 5bca2f2..7fdb94a 100644 --- a/backend/app/services/report_persistence.py +++ b/backend/app/services/report_persistence.py @@ -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"): diff --git a/backend/app/services/tls_report_persistence.py b/backend/app/services/tls_report_persistence.py index 68db3b9..a3b69f7 100644 --- a/backend/app/services/tls_report_persistence.py +++ b/backend/app/services/tls_report_persistence.py @@ -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 diff --git a/backend/app/services/workspaces.py b/backend/app/services/workspaces.py new file mode 100644 index 0000000..9eb14a2 --- /dev/null +++ b/backend/app/services/workspaces.py @@ -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) diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index ed0c15d..1f23347 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -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 diff --git a/backend/app/tests/test_workspaces.py b/backend/app/tests/test_workspaces.py new file mode 100644 index 0000000..af4aff7 --- /dev/null +++ b/backend/app/tests/test_workspaces.py @@ -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 diff --git a/docs/milestones.md b/docs/milestones.md index d0d36cb..08db944 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -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. diff --git a/docs/reference/database.md b/docs/reference/database.md index c11fa0c..a6f7c0a 100644 --- a/docs/reference/database.md +++ b/docs/reference/database.md @@ -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. diff --git a/docs/reference/workspaces.md b/docs/reference/workspaces.md new file mode 100644 index 0000000..d049cc0 --- /dev/null +++ b/docs/reference/workspaces.md @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml index 1a5c590..0188404 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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: