feat: add workspace tenant foundations
This commit is contained in:
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user