diff --git a/src/api/iam/dependencies/workspace.py b/src/api/iam/dependencies/workspace.py new file mode 100644 index 00000000..64fd40b4 --- /dev/null +++ b/src/api/iam/dependencies/workspace.py @@ -0,0 +1,31 @@ +"""FastAPI dependency injection for workspace repository. + +Provides workspace repository instances for route handlers +using FastAPI's dependency injection system. +""" + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from iam.dependencies.outbox import get_outbox_repository +from iam.infrastructure.workspace_repository import WorkspaceRepository +from infrastructure.database.dependencies import get_write_session +from infrastructure.outbox.repository import OutboxRepository + + +def get_workspace_repository( + session: Annotated[AsyncSession, Depends(get_write_session)], + outbox: Annotated[OutboxRepository, Depends(get_outbox_repository)], +) -> WorkspaceRepository: + """Get WorkspaceRepository instance. + + Args: + session: Async database session + outbox: Outbox repository for transactional outbox pattern + + Returns: + WorkspaceRepository instance with outbox pattern enabled + """ + return WorkspaceRepository(session=session, outbox=outbox) diff --git a/src/api/iam/infrastructure/models.py b/src/api/iam/infrastructure/models.py deleted file mode 100644 index 682a8e40..00000000 --- a/src/api/iam/infrastructure/models.py +++ /dev/null @@ -1,143 +0,0 @@ -"""SQLAlchemy ORM models for IAM bounded context. - -These models map to database tables and are used by repository implementations. -They store only metadata - authorization data (membership, roles) is stored in SpiceDB. -""" - -from datetime import datetime - -from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column - -from infrastructure.database.models import Base, TimestampMixin - - -class GroupModel(Base, TimestampMixin): - """ORM model for groups table (metadata only). - - Stores group metadata in PostgreSQL. Membership relationships are - managed through SpiceDB, not as database columns. - - Note: Group names are NOT globally unique - per-tenant uniqueness - is enforced at the application level. - - Foreign Key Constraint: - - tenant_id references tenants.id with CASCADE delete - - When a tenant is deleted, all groups are cascade deleted - - Group deletion must be handled in service layer to emit events - """ - - __tablename__ = "groups" - - id: Mapped[str] = mapped_column(String(26), primary_key=True) - tenant_id: Mapped[str] = mapped_column( - String(26), - ForeignKey("tenants.id", ondelete="CASCADE"), - nullable=False, - index=True, - ) - name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) - - def __repr__(self) -> str: - """Return string representation.""" - return ( - f"" - ) - - -class UserModel(Base, TimestampMixin): - """ORM model for users table (metadata only). - - Stores user metadata in PostgreSQL. Users are provisioned from SSO - and this table only stores minimal metadata for lookup and reference. - - Note: id is VARCHAR(255) to accommodate external SSO IDs (UUIDs, Auth0, etc.) - """ - - __tablename__ = "users" - - id: Mapped[str] = mapped_column(String(255), primary_key=True) - username: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - - def __repr__(self) -> str: - """Return string representation.""" - return f"" - - -class TenantModel(Base, TimestampMixin): - """ORM model for tenants table. - - Stores tenant metadata in PostgreSQL. Tenants represent organizations - and are the top-level isolation boundary in the system. - - Note: Tenant names are globally unique across the entire system. - """ - - __tablename__ = "tenants" - - id: Mapped[str] = mapped_column(String(26), primary_key=True) - name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - - def __repr__(self) -> str: - """Return string representation.""" - return f"" - - -class APIKeyModel(Base, TimestampMixin): - """ORM model for api_keys table. - - Stores API key metadata in PostgreSQL. The key_hash is the only - sensitive data stored - the plaintext secret is never persisted. - - Notes: - - created_by_user_id is VARCHAR(255) to match users.id (external SSO IDs) - This is for audit trail only - authorization is handled by SpiceDB. - - tenant_id is VARCHAR(26) for ULID format - - key_hash is unique for authentication lookup - - prefix allows key identification without exposing the full key - - Per-user key names are unique within a tenant - - Foreign Key Constraint: - - tenant_id references tenants.id with CASCADE delete - - When a tenant is deleted, all API keys are cascade deleted - - API key revocation must be handled in service layer to emit events - """ - - __tablename__ = "api_keys" - - id: Mapped[str] = mapped_column(String(26), primary_key=True) - created_by_user_id: Mapped[str] = mapped_column( - String(255), nullable=False, index=True - ) - tenant_id: Mapped[str] = mapped_column( - String(26), - ForeignKey("tenants.id", ondelete="CASCADE"), - nullable=False, - index=True, - ) - name: Mapped[str] = mapped_column(String(255), nullable=False) - key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - prefix: Mapped[str] = mapped_column(String(12), nullable=False, index=True) - expires_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) - last_used_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - is_revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - - __table_args__ = ( - UniqueConstraint( - "tenant_id", - "created_by_user_id", - "name", - name="uq_api_keys_tenant_user_name", - ), - ) - - def __repr__(self) -> str: - """Return string representation.""" - return ( - f"" - ) diff --git a/src/api/iam/infrastructure/models/__init__.py b/src/api/iam/infrastructure/models/__init__.py new file mode 100644 index 00000000..58aaab47 --- /dev/null +++ b/src/api/iam/infrastructure/models/__init__.py @@ -0,0 +1,19 @@ +"""SQLAlchemy ORM models for IAM bounded context. + +These models map to database tables and are used by repository implementations. +They store only metadata - authorization data (membership, roles) is stored in SpiceDB. +""" + +from iam.infrastructure.models.api_key import APIKeyModel +from iam.infrastructure.models.group import GroupModel +from iam.infrastructure.models.tenant import TenantModel +from iam.infrastructure.models.user import UserModel +from iam.infrastructure.models.workspace import WorkspaceModel + +__all__ = [ + "APIKeyModel", + "GroupModel", + "TenantModel", + "UserModel", + "WorkspaceModel", +] diff --git a/src/api/iam/infrastructure/models/api_key.py b/src/api/iam/infrastructure/models/api_key.py new file mode 100644 index 00000000..48c81cf0 --- /dev/null +++ b/src/api/iam/infrastructure/models/api_key.py @@ -0,0 +1,72 @@ +"""SQLAlchemy ORM model for the api_keys table. + +Stores API key metadata in PostgreSQL. The key_hash is the only +sensitive data stored - the plaintext secret is never persisted. +""" + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from infrastructure.database.models import Base, TimestampMixin + + +class APIKeyModel(Base, TimestampMixin): + """ORM model for api_keys table. + + Stores API key metadata in PostgreSQL. The key_hash is the only + sensitive data stored - the plaintext secret is never persisted. + + Notes: + - created_by_user_id is VARCHAR(255) to match users.id (external SSO IDs) + This is for audit trail only - authorization is handled by SpiceDB. + - tenant_id is VARCHAR(26) for ULID format + - key_hash is unique for authentication lookup + - prefix allows key identification without exposing the full key + - Per-user key names are unique within a tenant + + Foreign Key Constraint: + - tenant_id references tenants.id with CASCADE delete + - When a tenant is deleted, all API keys are cascade deleted + - API key revocation must be handled in service layer to emit events + """ + + __tablename__ = "api_keys" + + id: Mapped[str] = mapped_column(String(26), primary_key=True) + created_by_user_id: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + tenant_id: Mapped[str] = mapped_column( + String(26), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + prefix: Mapped[str] = mapped_column(String(12), nullable=False, index=True) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + last_used_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + is_revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "created_by_user_id", + "name", + name="uq_api_keys_tenant_user_name", + ), + ) + + def __repr__(self) -> str: + """Return string representation.""" + return ( + f"" + ) diff --git a/src/api/iam/infrastructure/models/group.py b/src/api/iam/infrastructure/models/group.py new file mode 100644 index 00000000..0bb9279c --- /dev/null +++ b/src/api/iam/infrastructure/models/group.py @@ -0,0 +1,43 @@ +"""SQLAlchemy ORM model for the groups table. + +Stores group metadata in PostgreSQL. Membership relationships are +managed through SpiceDB, not as database columns. +""" + +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from infrastructure.database.models import Base, TimestampMixin + + +class GroupModel(Base, TimestampMixin): + """ORM model for groups table (metadata only). + + Stores group metadata in PostgreSQL. Membership relationships are + managed through SpiceDB, not as database columns. + + Note: Group names are NOT globally unique - per-tenant uniqueness + is enforced at the application level. + + Foreign Key Constraint: + - tenant_id references tenants.id with RESTRICT delete + - Application layer must explicitly delete groups before tenant deletion + - This ensures GroupDeleted domain events are emitted for SpiceDB cleanup + """ + + __tablename__ = "groups" + + id: Mapped[str] = mapped_column(String(26), primary_key=True) + tenant_id: Mapped[str] = mapped_column( + String(26), + ForeignKey("tenants.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + + def __repr__(self) -> str: + """Return string representation.""" + return ( + f"" + ) diff --git a/src/api/iam/infrastructure/models/tenant.py b/src/api/iam/infrastructure/models/tenant.py new file mode 100644 index 00000000..2e7f94d3 --- /dev/null +++ b/src/api/iam/infrastructure/models/tenant.py @@ -0,0 +1,32 @@ +"""SQLAlchemy ORM model for the tenants table. + +Stores tenant metadata in PostgreSQL. Tenants represent organizations +and are the top-level isolation boundary in the system. +""" + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from infrastructure.database.models import Base, TimestampMixin + + +class TenantModel(Base, TimestampMixin): + """ORM model for tenants table. + + Stores tenant metadata in PostgreSQL. Tenants represent organizations + and are the top-level isolation boundary in the system. + + Note: Tenant names are globally unique across the entire system. + """ + + __tablename__ = "tenants" + + id: Mapped[str] = mapped_column(String(26), primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + + # Relationships + workspaces = relationship("WorkspaceModel", back_populates="tenant") + + def __repr__(self) -> str: + """Return string representation.""" + return f"" diff --git a/src/api/iam/infrastructure/models/user.py b/src/api/iam/infrastructure/models/user.py new file mode 100644 index 00000000..6f834822 --- /dev/null +++ b/src/api/iam/infrastructure/models/user.py @@ -0,0 +1,29 @@ +"""SQLAlchemy ORM model for the users table. + +Stores user metadata in PostgreSQL. Users are provisioned from SSO +and this table only stores minimal metadata for lookup and reference. +""" + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from infrastructure.database.models import Base, TimestampMixin + + +class UserModel(Base, TimestampMixin): + """ORM model for users table (metadata only). + + Stores user metadata in PostgreSQL. Users are provisioned from SSO + and this table only stores minimal metadata for lookup and reference. + + Note: id is VARCHAR(255) to accommodate external SSO IDs (UUIDs, Auth0, etc.) + """ + + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(255), primary_key=True) + username: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + + def __repr__(self) -> str: + """Return string representation.""" + return f"" diff --git a/src/api/iam/infrastructure/models/workspace.py b/src/api/iam/infrastructure/models/workspace.py new file mode 100644 index 00000000..91292658 --- /dev/null +++ b/src/api/iam/infrastructure/models/workspace.py @@ -0,0 +1,76 @@ +"""SQLAlchemy ORM model for the workspaces table. + +Stores workspace metadata in PostgreSQL. Workspaces organize knowledge +graphs within a tenant. +""" + +from sqlalchemy import Boolean, ForeignKey, Index, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from infrastructure.database.models import Base, TimestampMixin + + +class WorkspaceModel(Base, TimestampMixin): + """ORM model for workspaces table. + + Stores workspace metadata in PostgreSQL. Workspaces organize knowledge + graphs within a tenant. Each tenant has exactly one root workspace + (auto-created on tenant creation) and can have multiple child workspaces. + + Foreign Key Constraints: + - tenant_id references tenants.id with RESTRICT delete + Application must delete workspaces before tenant deletion + - parent_workspace_id references workspaces.id with RESTRICT delete + Cannot delete a parent workspace while children exist + + Partial Unique Index: + - Only one root workspace (is_root=TRUE) per tenant + """ + + __tablename__ = "workspaces" + + id: Mapped[str] = mapped_column(String(26), primary_key=True) + tenant_id: Mapped[str] = mapped_column( + String(26), + ForeignKey("tenants.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + parent_workspace_id: Mapped[str | None] = mapped_column( + String(26), + ForeignKey("workspaces.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + is_root: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + # Relationships + tenant = relationship("TenantModel", back_populates="workspaces") + parent_workspace = relationship( + "WorkspaceModel", + remote_side="WorkspaceModel.id", + back_populates="child_workspaces", + ) + child_workspaces = relationship( + "WorkspaceModel", + back_populates="parent_workspace", + ) + + __table_args__ = ( + Index("idx_workspaces_name_tenant", "name", "tenant_id"), + Index( + "idx_workspaces_root_unique", + "tenant_id", + "is_root", + unique=True, + postgresql_where=(is_root == True), # noqa: E712 + ), + ) + + def __repr__(self) -> str: + """Return string representation.""" + return ( + f"" + ) diff --git a/src/api/iam/infrastructure/observability/__init__.py b/src/api/iam/infrastructure/observability/__init__.py index c5f7c2b5..0b78c976 100644 --- a/src/api/iam/infrastructure/observability/__init__.py +++ b/src/api/iam/infrastructure/observability/__init__.py @@ -11,9 +11,11 @@ DefaultGroupRepositoryProbe, DefaultTenantRepositoryProbe, DefaultUserRepositoryProbe, + DefaultWorkspaceRepositoryProbe, GroupRepositoryProbe, TenantRepositoryProbe, UserRepositoryProbe, + WorkspaceRepositoryProbe, ) __all__ = [ @@ -25,4 +27,6 @@ "DefaultUserRepositoryProbe", "TenantRepositoryProbe", "DefaultTenantRepositoryProbe", + "WorkspaceRepositoryProbe", + "DefaultWorkspaceRepositoryProbe", ] diff --git a/src/api/iam/infrastructure/observability/repository_probe.py b/src/api/iam/infrastructure/observability/repository_probe.py index affce841..1431b270 100644 --- a/src/api/iam/infrastructure/observability/repository_probe.py +++ b/src/api/iam/infrastructure/observability/repository_probe.py @@ -1,7 +1,7 @@ """Domain probe for IAM repository operations. Following Domain-Oriented Observability patterns, this probe captures -domain-significant events related to group, user, and tenant repository operations. +domain-significant events related to group, user, tenant, and workspace repository operations. """ from __future__ import annotations @@ -309,3 +309,100 @@ def duplicate_tenant_name(self, name: str) -> None: name=name, **self._get_context_kwargs(), ) + + +class WorkspaceRepositoryProbe(Protocol): + """Domain probe for workspace repository operations. + + Records domain events during workspace persistence operations. + """ + + def workspace_saved(self, workspace_id: str, tenant_id: str) -> None: + """Record that a workspace was successfully saved.""" + ... + + def workspace_retrieved(self, workspace_id: str) -> None: + """Record that a workspace was retrieved.""" + ... + + def workspace_not_found(self, **kwargs: Any) -> None: + """Record that a workspace was not found.""" + ... + + def workspace_deleted(self, workspace_id: str) -> None: + """Record that a workspace was deleted.""" + ... + + def workspaces_listed(self, tenant_id: str, count: int) -> None: + """Record that workspaces were listed for a tenant.""" + ... + + def with_context(self, context: ObservationContext) -> WorkspaceRepositoryProbe: + """Create a new probe with observation context bound.""" + ... + + +class DefaultWorkspaceRepositoryProbe: + """Default implementation of WorkspaceRepositoryProbe using structlog.""" + + def __init__( + self, + logger: structlog.stdlib.BoundLogger | None = None, + context: ObservationContext | None = None, + ): + self._logger = logger or structlog.get_logger() + self._context = context + + def _get_context_kwargs(self) -> dict[str, Any]: + """Get context metadata as kwargs for logging.""" + if self._context is None: + return {} + return self._context.as_dict() + + def with_context( + self, context: ObservationContext + ) -> DefaultWorkspaceRepositoryProbe: + """Create a new probe with observation context bound.""" + return DefaultWorkspaceRepositoryProbe(logger=self._logger, context=context) + + def workspace_saved(self, workspace_id: str, tenant_id: str) -> None: + """Record that a workspace was successfully saved.""" + self._logger.info( + "workspace_saved", + workspace_id=workspace_id, + tenant_id=tenant_id, + **self._get_context_kwargs(), + ) + + def workspace_retrieved(self, workspace_id: str) -> None: + """Record that a workspace was retrieved.""" + self._logger.debug( + "workspace_retrieved", + workspace_id=workspace_id, + **self._get_context_kwargs(), + ) + + def workspace_not_found(self, **kwargs: Any) -> None: + """Record that a workspace was not found.""" + self._logger.debug( + "workspace_not_found", + **kwargs, + **self._get_context_kwargs(), + ) + + def workspace_deleted(self, workspace_id: str) -> None: + """Record that a workspace was deleted.""" + self._logger.info( + "workspace_deleted", + workspace_id=workspace_id, + **self._get_context_kwargs(), + ) + + def workspaces_listed(self, tenant_id: str, count: int) -> None: + """Record that workspaces were listed for a tenant.""" + self._logger.debug( + "workspaces_listed", + tenant_id=tenant_id, + count=count, + **self._get_context_kwargs(), + ) diff --git a/src/api/iam/infrastructure/workspace_repository.py b/src/api/iam/infrastructure/workspace_repository.py new file mode 100644 index 00000000..53d9b7ab --- /dev/null +++ b/src/api/iam/infrastructure/workspace_repository.py @@ -0,0 +1,278 @@ +"""PostgreSQL implementation of IWorkspaceRepository. + +This repository manages workspace metadata storage in PostgreSQL. +Unlike GroupRepository, it doesn't need SpiceDB for membership hydration +since workspaces don't have members yet (Phase 3). + +Write operations use the transactional outbox pattern - domain events are +collected from the aggregate and appended to the outbox table. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from iam.domain.aggregates import Workspace +from iam.domain.value_objects import TenantId, WorkspaceId +from iam.infrastructure.models import WorkspaceModel +from iam.infrastructure.observability import ( + DefaultWorkspaceRepositoryProbe, + WorkspaceRepositoryProbe, +) +from iam.infrastructure.outbox import IAMEventSerializer +from iam.ports.repositories import IWorkspaceRepository + +if TYPE_CHECKING: + from infrastructure.outbox.repository import OutboxRepository + + +class WorkspaceRepository(IWorkspaceRepository): + """Repository managing PostgreSQL storage for Workspace aggregates. + + This implementation stores workspace metadata in PostgreSQL only. + Workspaces are simple aggregates with no complex relationships + requiring SpiceDB hydration (that comes in Phase 3). + + Write operations use the transactional outbox pattern: + - Domain events are collected from the aggregate + - Events are appended to the outbox table (same transaction as PostgreSQL) + - The outbox worker processes events if needed + """ + + def __init__( + self, + session: AsyncSession, + outbox: "OutboxRepository", + probe: WorkspaceRepositoryProbe | None = None, + serializer: IAMEventSerializer | None = None, + ) -> None: + """Initialize repository with database session and outbox. + + Args: + session: AsyncSession from FastAPI dependency injection + outbox: Outbox repository for the transactional outbox pattern + probe: Optional domain probe for observability + serializer: Optional event serializer for testability + """ + self._session = session + self._outbox = outbox + self._probe = probe or DefaultWorkspaceRepositoryProbe() + self._serializer = serializer or IAMEventSerializer() + + async def save(self, workspace: Workspace) -> None: + """Persist workspace metadata to PostgreSQL, events to outbox. + + Uses the transactional outbox pattern: domain events are appended + to the outbox table within the same database transaction. + + Args: + workspace: The Workspace aggregate to persist + """ + # Upsert workspace metadata in PostgreSQL + stmt = select(WorkspaceModel).where(WorkspaceModel.id == workspace.id.value) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + + if model: + # Update existing + model.name = workspace.name + model.parent_workspace_id = ( + workspace.parent_workspace_id.value + if workspace.parent_workspace_id + else None + ) + model.is_root = workspace.is_root + model.updated_at = workspace.updated_at + else: + # Create new + model = WorkspaceModel( + id=workspace.id.value, + tenant_id=workspace.tenant_id.value, + name=workspace.name, + parent_workspace_id=( + workspace.parent_workspace_id.value + if workspace.parent_workspace_id + else None + ), + is_root=workspace.is_root, + created_at=workspace.created_at, + updated_at=workspace.updated_at, + ) + self._session.add(model) + + # Flush to catch integrity errors before outbox writes + await self._session.flush() + + # Collect, serialize, and append events from the aggregate to outbox + events = workspace.collect_events() + for event in events: + payload = self._serializer.serialize(event) + await self._outbox.append( + event_type=type(event).__name__, + payload=payload, + occurred_at=event.occurred_at, + aggregate_type="workspace", + aggregate_id=workspace.id.value, + ) + + self._probe.workspace_saved(workspace.id.value, workspace.tenant_id.value) + + async def get_by_id(self, workspace_id: WorkspaceId) -> Workspace | None: + """Fetch workspace metadata from PostgreSQL. + + Args: + workspace_id: The unique identifier of the workspace + + Returns: + The Workspace aggregate, or None if not found + """ + stmt = select(WorkspaceModel).where(WorkspaceModel.id == workspace_id.value) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + + if model is None: + self._probe.workspace_not_found(workspace_id=workspace_id.value) + return None + + workspace = self._to_domain(model) + self._probe.workspace_retrieved(workspace.id.value) + return workspace + + async def get_by_name(self, tenant_id: TenantId, name: str) -> Workspace | None: + """Fetch workspace by name within a tenant. + + Args: + tenant_id: The tenant to search within + name: The workspace name + + Returns: + The Workspace aggregate, or None if not found + """ + stmt = select(WorkspaceModel).where( + WorkspaceModel.tenant_id == tenant_id.value, + WorkspaceModel.name == name, + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + + if model is None: + self._probe.workspace_not_found( + tenant_id=tenant_id.value, + name=name, + ) + return None + + workspace = self._to_domain(model) + self._probe.workspace_retrieved(workspace.id.value) + return workspace + + async def get_root_workspace(self, tenant_id: TenantId) -> Workspace | None: + """Fetch the root workspace for a tenant. + + Args: + tenant_id: The tenant to find the root workspace for + + Returns: + The root Workspace aggregate, or None if not found + """ + stmt = select(WorkspaceModel).where( + WorkspaceModel.tenant_id == tenant_id.value, + WorkspaceModel.is_root == True, # noqa: E712 + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + + if model is None: + self._probe.workspace_not_found( + tenant_id=tenant_id.value, + is_root=True, + ) + return None + + workspace = self._to_domain(model) + self._probe.workspace_retrieved(workspace.id.value) + return workspace + + async def list_by_tenant(self, tenant_id: TenantId) -> list[Workspace]: + """Fetch all workspaces in a tenant. + + Args: + tenant_id: The tenant to list workspaces for + + Returns: + List of Workspace aggregates in the tenant + """ + stmt = select(WorkspaceModel).where(WorkspaceModel.tenant_id == tenant_id.value) + result = await self._session.execute(stmt) + models = result.scalars().all() + + workspaces = [self._to_domain(model) for model in models] + self._probe.workspaces_listed(tenant_id.value, len(workspaces)) + return workspaces + + async def delete(self, workspace: Workspace) -> bool: + """Delete workspace from PostgreSQL and emit domain events. + + The workspace should have mark_for_deletion() called before this + method to record the WorkspaceDeleted event. + + Args: + workspace: The Workspace aggregate to delete (with deletion event recorded) + + Returns: + True if deleted, False if not found + """ + stmt = select(WorkspaceModel).where(WorkspaceModel.id == workspace.id.value) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + + if model is None: + return False + + # Collect and append deletion event to outbox before deletion + events = workspace.collect_events() + for event in events: + payload = self._serializer.serialize(event) + await self._outbox.append( + event_type=type(event).__name__, + payload=payload, + occurred_at=event.occurred_at, + aggregate_type="workspace", + aggregate_id=workspace.id.value, + ) + + # Delete from PostgreSQL + await self._session.delete(model) + await self._session.flush() + + self._probe.workspace_deleted(workspace.id.value) + return True + + def _to_domain(self, model: WorkspaceModel) -> Workspace: + """Convert a WorkspaceModel to a Workspace domain aggregate. + + Reconstitutes the aggregate from database state without generating + any domain events (this is a read operation, not a mutation). + + Args: + model: The SQLAlchemy model to convert + + Returns: + A Workspace domain aggregate + """ + return Workspace( + id=WorkspaceId(value=model.id), + tenant_id=TenantId(value=model.tenant_id), + name=model.name, + parent_workspace_id=( + WorkspaceId(value=model.parent_workspace_id) + if model.parent_workspace_id + else None + ), + is_root=model.is_root, + created_at=model.created_at, + updated_at=model.updated_at, + ) diff --git a/src/api/iam/ports/repositories.py b/src/api/iam/ports/repositories.py index bf66320c..c453e9a1 100644 --- a/src/api/iam/ports/repositories.py +++ b/src/api/iam/ports/repositories.py @@ -9,8 +9,8 @@ from typing import Callable, Protocol, runtime_checkable -from iam.domain.aggregates import APIKey, Group, Tenant, User -from iam.domain.value_objects import APIKeyId, GroupId, TenantId, UserId +from iam.domain.aggregates import APIKey, Group, Tenant, User, Workspace +from iam.domain.value_objects import APIKeyId, GroupId, TenantId, UserId, WorkspaceId from shared_kernel.authorization.protocols import AuthorizationProvider @@ -217,6 +217,95 @@ async def is_last_admin( ... +@runtime_checkable +class IWorkspaceRepository(Protocol): + """Repository for Workspace aggregate persistence. + + Simple repository for workspace metadata. Workspaces organize knowledge + graphs within a tenant. Unlike GroupRepository, no SpiceDB member + hydration is needed (workspace members are managed in a later phase). + + Write operations use the transactional outbox pattern to emit domain + events for SpiceDB relationship management. + """ + + async def save(self, workspace: Workspace) -> None: + """Persist a workspace aggregate. + + Creates a new workspace or updates an existing one. Persists workspace + metadata to PostgreSQL and domain events to the outbox. + + Args: + workspace: The Workspace aggregate to persist + """ + ... + + async def get_by_id(self, workspace_id: WorkspaceId) -> Workspace | None: + """Retrieve a workspace by its ID. + + Args: + workspace_id: The unique identifier of the workspace + + Returns: + The Workspace aggregate, or None if not found + """ + ... + + async def get_by_name(self, tenant_id: TenantId, name: str) -> Workspace | None: + """Retrieve a workspace by name within a tenant. + + Used for application-level uniqueness checks before creating + a new workspace. + + Args: + tenant_id: The tenant to search within + name: The workspace name + + Returns: + The Workspace aggregate, or None if not found + """ + ... + + async def get_root_workspace(self, tenant_id: TenantId) -> Workspace | None: + """Retrieve the root workspace for a tenant. + + Each tenant has exactly one root workspace (is_root=True). + + Args: + tenant_id: The tenant to find the root workspace for + + Returns: + The root Workspace aggregate, or None if not found + """ + ... + + async def list_by_tenant(self, tenant_id: TenantId) -> list[Workspace]: + """List all workspaces in a tenant. + + Args: + tenant_id: The tenant to list workspaces for + + Returns: + List of Workspace aggregates in the tenant + """ + ... + + async def delete(self, workspace: Workspace) -> bool: + """Delete a workspace and emit domain events. + + The workspace should have mark_for_deletion() called before this + method to record the WorkspaceDeleted event. The outbox worker + will handle removing relationships from SpiceDB. + + Args: + workspace: The Workspace aggregate to delete (with deletion event recorded) + + Returns: + True if deleted, False if not found + """ + ... + + @runtime_checkable class IAPIKeyRepository(Protocol): """Repository for APIKey aggregate persistence. diff --git a/src/api/infrastructure/migrations/versions/205809969bf4_create_workspaces_table.py b/src/api/infrastructure/migrations/versions/205809969bf4_create_workspaces_table.py new file mode 100644 index 00000000..5320767c --- /dev/null +++ b/src/api/infrastructure/migrations/versions/205809969bf4_create_workspaces_table.py @@ -0,0 +1,87 @@ +"""create workspaces table + +Revision ID: 205809969bf4 +Revises: 36612dcd7676 +Create Date: 2026-02-06 15:35:32.767286 + +Creates the workspaces table for organizing knowledge graphs within tenants. +Uses RESTRICT FK constraints to force application-level cascading and ensure +domain events are emitted for SpiceDB cleanup. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "205809969bf4" +down_revision: Union[str, Sequence[str], None] = "36612dcd7676" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create workspaces table with RESTRICT FK constraints. + + Key constraints: + - tenant_id FK with RESTRICT (forces explicit workspace deletion before tenant) + - parent_workspace_id self-FK with RESTRICT (prevents deleting parent with children) + - Partial unique index ensures only one root workspace per tenant + """ + op.create_table( + "workspaces", + sa.Column("id", sa.String(26), primary_key=True), + sa.Column( + "tenant_id", + sa.String(26), + sa.ForeignKey("tenants.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("name", sa.String(255), nullable=False), + sa.Column( + "parent_workspace_id", + sa.String(26), + sa.ForeignKey("workspaces.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column("is_root", sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + ), + ) + + # Index on tenant_id for listing workspaces by tenant + op.create_index("idx_workspaces_tenant_id", "workspaces", ["tenant_id"]) + + # Index on parent_workspace_id for hierarchy queries + op.create_index("idx_workspaces_parent", "workspaces", ["parent_workspace_id"]) + + # Composite index for name + tenant lookups + op.create_index("idx_workspaces_name_tenant", "workspaces", ["name", "tenant_id"]) + + # Partial unique index: only one root workspace per tenant + op.create_index( + "idx_workspaces_root_unique", + "workspaces", + ["tenant_id", "is_root"], + unique=True, + postgresql_where=sa.text("is_root = TRUE"), + ) + + +def downgrade() -> None: + """Drop workspaces table and all associated indexes.""" + op.drop_index("idx_workspaces_root_unique", table_name="workspaces") + op.drop_index("idx_workspaces_name_tenant", table_name="workspaces") + op.drop_index("idx_workspaces_parent", table_name="workspaces") + op.drop_index("idx_workspaces_tenant_id", table_name="workspaces") + op.drop_table("workspaces") diff --git a/src/api/infrastructure/migrations/versions/36612dcd7676_fix_groups_tenant_cascade_constraint.py b/src/api/infrastructure/migrations/versions/36612dcd7676_fix_groups_tenant_cascade_constraint.py new file mode 100644 index 00000000..10c3d2b4 --- /dev/null +++ b/src/api/infrastructure/migrations/versions/36612dcd7676_fix_groups_tenant_cascade_constraint.py @@ -0,0 +1,56 @@ +"""fix groups tenant cascade constraint + +Revision ID: 36612dcd7676 +Revises: 193b7c6ad230 +Create Date: 2026-02-06 15:34:59.572180 + +Changes CASCADE to RESTRICT on groups.tenant_id FK to force application-level +cascading. This ensures GroupDeleted domain events are emitted when groups are +removed, preventing orphaned SpiceDB relationships. +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "36612dcd7676" +down_revision: Union[str, Sequence[str], None] = "193b7c6ad230" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Change groups.tenant_id FK from CASCADE to RESTRICT. + + RESTRICT forces the application layer to explicitly delete groups + before deleting a tenant, ensuring domain events are emitted for + SpiceDB cleanup. + """ + # Drop the existing CASCADE FK constraint + op.drop_constraint("fk_groups_tenant_id", "groups", type_="foreignkey") + + # Re-create with RESTRICT instead of CASCADE + op.create_foreign_key( + "fk_groups_tenant_id", + "groups", + "tenants", + ["tenant_id"], + ["id"], + ondelete="RESTRICT", + ) + + +def downgrade() -> None: + """Revert groups.tenant_id FK to CASCADE for rollback.""" + op.drop_constraint("fk_groups_tenant_id", "groups", type_="foreignkey") + + op.create_foreign_key( + "fk_groups_tenant_id", + "groups", + "tenants", + ["tenant_id"], + ["id"], + ondelete="CASCADE", + ) diff --git a/src/api/infrastructure/settings.py b/src/api/infrastructure/settings.py index 885d98af..9e91e1ff 100644 --- a/src/api/infrastructure/settings.py +++ b/src/api/infrastructure/settings.py @@ -271,6 +271,7 @@ class IAMSettings(BaseSettings): Environment variables: KARTOGRAPH_IAM_DEFAULT_TENANT_NAME: Default tenant name for single-tenant mode (default: default) + KARTOGRAPH_IAM_DEFAULT_WORKSPACE_NAME: Default root workspace name (default: None, uses tenant name) """ model_config = SettingsConfigDict( @@ -285,6 +286,11 @@ class IAMSettings(BaseSettings): description="Default tenant name for single-tenant mode", ) + default_workspace_name: str | None = Field( + default=None, + description="Default root workspace name (if None, uses tenant name)", + ) + @lru_cache def get_iam_settings() -> IAMSettings: diff --git a/src/api/tests/unit/iam/infrastructure/test_workspace_repository.py b/src/api/tests/unit/iam/infrastructure/test_workspace_repository.py new file mode 100644 index 00000000..ae716002 --- /dev/null +++ b/src/api/tests/unit/iam/infrastructure/test_workspace_repository.py @@ -0,0 +1,808 @@ +"""Unit tests for WorkspaceRepository. + +Following TDD principles - tests verify repository behavior with mocked dependencies. +Tests cover all IWorkspaceRepository protocol methods including outbox event emission, +constraint enforcement, and edge cases. +""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from iam.domain.aggregates import Workspace +from iam.domain.value_objects import TenantId, WorkspaceId +from iam.infrastructure.models import WorkspaceModel +from iam.infrastructure.workspace_repository import WorkspaceRepository +from iam.ports.repositories import IWorkspaceRepository + + +@pytest.fixture +def mock_session(): + """Create mock async session.""" + session = AsyncMock() + return session + + +@pytest.fixture +def mock_probe(): + """Create mock repository probe.""" + probe = MagicMock() + return probe + + +@pytest.fixture +def mock_outbox(): + """Create mock outbox repository.""" + outbox = MagicMock() + outbox.append = AsyncMock() + return outbox + + +@pytest.fixture +def mock_serializer(): + """Create mock event serializer.""" + serializer = MagicMock() + serializer.serialize.return_value = {"test": "payload"} + return serializer + + +@pytest.fixture +def repository(mock_session, mock_probe, mock_outbox): + """Create repository with mock dependencies.""" + return WorkspaceRepository( + session=mock_session, + outbox=mock_outbox, + probe=mock_probe, + ) + + +@pytest.fixture +def tenant_id(): + """Create a test tenant ID.""" + return TenantId.generate() + + +@pytest.fixture +def now(): + """Create a fixed timestamp for testing.""" + return datetime.now(UTC) + + +class TestProtocolCompliance: + """Tests for protocol compliance.""" + + def test_implements_protocol(self, repository): + """Repository should implement IWorkspaceRepository protocol.""" + assert isinstance(repository, IWorkspaceRepository) + + +class TestSave: + """Tests for save method.""" + + @pytest.mark.asyncio + async def test_save_workspace_creates_in_database( + self, repository, mock_session, tenant_id + ): + """Should add new workspace model to session when workspace doesn't exist.""" + workspace = Workspace.create_root( + name="Root Workspace", + tenant_id=tenant_id, + ) + + # Mock session to return None (workspace doesn't exist) + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.save(workspace) + + # Should add new model + mock_session.add.assert_called_once() + added_model = mock_session.add.call_args[0][0] + assert isinstance(added_model, WorkspaceModel) + assert added_model.id == workspace.id.value + assert added_model.tenant_id == tenant_id.value + assert added_model.name == "Root Workspace" + assert added_model.is_root is True + assert added_model.parent_workspace_id is None + + @pytest.mark.asyncio + async def test_save_workspace_updates_existing( + self, repository, mock_session, tenant_id, now + ): + """Should update existing workspace model when workspace exists.""" + workspace_id = WorkspaceId.generate() + workspace = Workspace( + id=workspace_id, + tenant_id=tenant_id, + name="Updated Name", + parent_workspace_id=None, + is_root=True, + created_at=now, + updated_at=now, + ) + + # Mock existing workspace + existing_model = WorkspaceModel( + id=workspace_id.value, + tenant_id=tenant_id.value, + name="Old Name", + parent_workspace_id=None, + is_root=True, + created_at=now, + updated_at=now, + ) + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = existing_model + mock_session.execute.return_value = mock_result + + await repository.save(workspace) + + # Should not add, should update + mock_session.add.assert_not_called() + assert existing_model.name == "Updated Name" + + @pytest.mark.asyncio + async def test_save_workspace_emits_events_to_outbox( + self, repository, mock_session, mock_outbox, tenant_id + ): + """Should append collected events to outbox when saving.""" + # Use factory to generate events + workspace = Workspace.create_root( + name="Root Workspace", + tenant_id=tenant_id, + ) + + # Mock session + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.save(workspace) + + # Should have appended WorkspaceCreated event to outbox + assert mock_outbox.append.call_count == 1 + call_kwargs = mock_outbox.append.call_args.kwargs + assert call_kwargs["event_type"] == "WorkspaceCreated" + assert call_kwargs["aggregate_type"] == "workspace" + assert call_kwargs["aggregate_id"] == workspace.id.value + + @pytest.mark.asyncio + async def test_save_child_workspace_with_parent( + self, repository, mock_session, tenant_id + ): + """Should save child workspace with parent reference.""" + parent_id = WorkspaceId.generate() + workspace = Workspace.create( + name="Child Workspace", + tenant_id=tenant_id, + parent_workspace_id=parent_id, + ) + + # Mock session to return None (workspace doesn't exist) + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.save(workspace) + + # Should add new model with parent reference + mock_session.add.assert_called_once() + added_model = mock_session.add.call_args[0][0] + assert added_model.parent_workspace_id == parent_id.value + assert added_model.is_root is False + + +class TestGetById: + """Tests for get_by_id method.""" + + @pytest.mark.asyncio + async def test_get_by_id_returns_workspace( + self, repository, mock_session, tenant_id, now + ): + """Should return workspace when found by ID.""" + workspace_id = WorkspaceId.generate() + model = WorkspaceModel( + id=workspace_id.value, + tenant_id=tenant_id.value, + name="Test Workspace", + parent_workspace_id=None, + is_root=True, + created_at=now, + updated_at=now, + ) + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = model + mock_session.execute.return_value = mock_result + + result = await repository.get_by_id(workspace_id) + + assert result is not None + assert result.id.value == workspace_id.value + assert result.tenant_id.value == tenant_id.value + assert result.name == "Test Workspace" + assert result.is_root is True + assert result.parent_workspace_id is None + + @pytest.mark.asyncio + async def test_get_by_id_returns_none_when_not_found( + self, repository, mock_session + ): + """Should return None when workspace doesn't exist.""" + workspace_id = WorkspaceId.generate() + + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + result = await repository.get_by_id(workspace_id) + + assert result is None + + @pytest.mark.asyncio + async def test_get_by_id_returns_workspace_with_parent( + self, repository, mock_session, tenant_id, now + ): + """Should return workspace with parent_workspace_id reconstituted.""" + workspace_id = WorkspaceId.generate() + parent_id = WorkspaceId.generate() + model = WorkspaceModel( + id=workspace_id.value, + tenant_id=tenant_id.value, + name="Child Workspace", + parent_workspace_id=parent_id.value, + is_root=False, + created_at=now, + updated_at=now, + ) + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = model + mock_session.execute.return_value = mock_result + + result = await repository.get_by_id(workspace_id) + + assert result is not None + assert result.parent_workspace_id is not None + assert result.parent_workspace_id.value == parent_id.value + assert result.is_root is False + + +class TestGetByName: + """Tests for get_by_name method.""" + + @pytest.mark.asyncio + async def test_get_by_name_returns_workspace( + self, repository, mock_session, tenant_id, now + ): + """Should return workspace when found by name in tenant.""" + workspace_id = WorkspaceId.generate() + model = WorkspaceModel( + id=workspace_id.value, + tenant_id=tenant_id.value, + name="Engineering", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ) + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = model + mock_session.execute.return_value = mock_result + + result = await repository.get_by_name(tenant_id, "Engineering") + + assert result is not None + assert result.name == "Engineering" + + @pytest.mark.asyncio + async def test_get_by_name_returns_none_when_not_found( + self, repository, mock_session, tenant_id + ): + """Should return None when workspace name doesn't exist in tenant.""" + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + result = await repository.get_by_name(tenant_id, "Nonexistent") + + assert result is None + + +class TestGetRootWorkspace: + """Tests for get_root_workspace method.""" + + @pytest.mark.asyncio + async def test_get_root_workspace_returns_root( + self, repository, mock_session, tenant_id, now + ): + """Should return root workspace for tenant.""" + workspace_id = WorkspaceId.generate() + model = WorkspaceModel( + id=workspace_id.value, + tenant_id=tenant_id.value, + name="Root", + parent_workspace_id=None, + is_root=True, + created_at=now, + updated_at=now, + ) + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = model + mock_session.execute.return_value = mock_result + + result = await repository.get_root_workspace(tenant_id) + + assert result is not None + assert result.is_root is True + assert result.parent_workspace_id is None + + @pytest.mark.asyncio + async def test_get_root_workspace_returns_none_when_not_found( + self, repository, mock_session, tenant_id + ): + """Should return None when no root workspace exists for tenant.""" + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + result = await repository.get_root_workspace(tenant_id) + + assert result is None + + +class TestListByTenant: + """Tests for list_by_tenant method.""" + + @pytest.mark.asyncio + async def test_list_by_tenant_returns_all_workspaces( + self, repository, mock_session, tenant_id, now + ): + """Should return all workspaces in a tenant.""" + models = [ + WorkspaceModel( + id=WorkspaceId.generate().value, + tenant_id=tenant_id.value, + name="Root", + parent_workspace_id=None, + is_root=True, + created_at=now, + updated_at=now, + ), + WorkspaceModel( + id=WorkspaceId.generate().value, + tenant_id=tenant_id.value, + name="Engineering", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ), + WorkspaceModel( + id=WorkspaceId.generate().value, + tenant_id=tenant_id.value, + name="Marketing", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ), + ] + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = models + mock_session.execute.return_value = mock_result + + result = await repository.list_by_tenant(tenant_id) + + assert len(result) == 3 + names = {w.name for w in result} + assert "Root" in names + assert "Engineering" in names + assert "Marketing" in names + + @pytest.mark.asyncio + async def test_list_by_tenant_returns_empty_when_none( + self, repository, mock_session, tenant_id + ): + """Should return empty list when tenant has no workspaces.""" + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + mock_session.execute.return_value = mock_result + + result = await repository.list_by_tenant(tenant_id) + + assert result == [] + + +class TestDelete: + """Tests for delete method.""" + + @pytest.mark.asyncio + async def test_delete_workspace_removes_from_database( + self, repository, mock_session, tenant_id, now + ): + """Should delete workspace from PostgreSQL.""" + workspace_id = WorkspaceId.generate() + workspace = Workspace( + id=workspace_id, + tenant_id=tenant_id, + name="To Delete", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ) + # Mark for deletion to record event + workspace.mark_for_deletion() + + model = WorkspaceModel( + id=workspace_id.value, + tenant_id=tenant_id.value, + name="To Delete", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ) + + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = model + mock_session.execute.return_value = mock_result + + result = await repository.delete(workspace) + + assert result is True + mock_session.delete.assert_called_once_with(model) + + @pytest.mark.asyncio + async def test_delete_workspace_emits_events_to_outbox( + self, repository, mock_session, mock_outbox, tenant_id, now + ): + """Should append WorkspaceDeleted event to outbox.""" + workspace_id = WorkspaceId.generate() + workspace = Workspace( + id=workspace_id, + tenant_id=tenant_id, + name="To Delete", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ) + # Mark for deletion to record event + workspace.mark_for_deletion() + + model = WorkspaceModel( + id=workspace_id.value, + tenant_id=tenant_id.value, + name="To Delete", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ) + + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = model + mock_session.execute.return_value = mock_result + + await repository.delete(workspace) + + # Should have appended WorkspaceDeleted event + calls = mock_outbox.append.call_args_list + event_types = [call.kwargs.get("event_type") for call in calls] + assert "WorkspaceDeleted" in event_types + + @pytest.mark.asyncio + async def test_delete_returns_false_when_not_found( + self, repository, mock_session, tenant_id, now + ): + """Should return False when workspace doesn't exist in database.""" + workspace = Workspace( + id=WorkspaceId.generate(), + tenant_id=tenant_id, + name="Ghost", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ) + + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + result = await repository.delete(workspace) + + assert result is False + + +class TestWorkspaceNameNotUniqueAcrossTenants: + """Tests for workspace name uniqueness behavior across tenants.""" + + @pytest.mark.asyncio + async def test_workspace_name_not_unique_across_tenants( + self, repository, mock_session, now + ): + """Same workspace name should be allowed in different tenants. + + Workspace names are unique within a tenant (application-level), + not globally. This test verifies two workspaces with the same name + can exist in different tenants. + """ + tenant_a = TenantId.generate() + tenant_b = TenantId.generate() + + workspace_a = Workspace.create_root(name="Root", tenant_id=tenant_a) + workspace_b = Workspace.create_root(name="Root", tenant_id=tenant_b) + + # Mock session for first save + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + # Both saves should succeed (no uniqueness violation) + await repository.save(workspace_a) + await repository.save(workspace_b) + + # Both adds should have been called + assert mock_session.add.call_count == 2 + + +class TestParentWorkspaceReference: + """Tests for parent workspace self-referential relationship.""" + + @pytest.mark.asyncio + async def test_parent_workspace_reference_works( + self, repository, mock_session, tenant_id, now + ): + """Should correctly save and retrieve child workspaces with parent references.""" + parent_id = WorkspaceId.generate() + child = Workspace.create( + name="Child", + tenant_id=tenant_id, + parent_workspace_id=parent_id, + ) + + # Mock session to return None (workspace doesn't exist) + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.save(child) + + # Verify parent_workspace_id was persisted + added_model = mock_session.add.call_args[0][0] + assert added_model.parent_workspace_id == parent_id.value + + +class TestRootWorkspaceConstraint: + """Tests for root workspace uniqueness constraint.""" + + @pytest.mark.asyncio + async def test_root_workspace_constraint_enforced( + self, repository, mock_session, tenant_id, now + ): + """Should verify only one root workspace per tenant. + + The partial unique index ensures at most one root workspace per tenant. + This unit test verifies that the model correctly sets is_root=True. + The actual database constraint enforcement is tested in integration tests. + """ + root = Workspace.create_root( + name="Root", + tenant_id=tenant_id, + ) + + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.save(root) + + added_model = mock_session.add.call_args[0][0] + assert added_model.is_root is True + + # A non-root workspace should have is_root=False + parent_id = WorkspaceId.generate() + child = Workspace.create( + name="Child", + tenant_id=tenant_id, + parent_workspace_id=parent_id, + ) + + await repository.save(child) + + child_model = mock_session.add.call_args[0][0] + assert child_model.is_root is False + + +class TestSerializerInjection: + """Tests for serializer dependency injection.""" + + @pytest.mark.asyncio + async def test_uses_injected_serializer( + self, mock_session, mock_outbox, mock_probe, mock_serializer + ): + """Should use injected serializer instead of creating default.""" + repository = WorkspaceRepository( + session=mock_session, + outbox=mock_outbox, + probe=mock_probe, + serializer=mock_serializer, + ) + + tenant_id = TenantId.generate() + workspace = Workspace.create_root( + name="Test Workspace", + tenant_id=tenant_id, + ) + + # Mock session + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.save(workspace) + + # Injected serializer should have been called + mock_serializer.serialize.assert_called() + + def test_uses_default_serializer_when_not_injected( + self, mock_session, mock_outbox, mock_probe + ): + """Should create default serializer when not injected.""" + from iam.infrastructure.outbox import IAMEventSerializer + + repository = WorkspaceRepository( + session=mock_session, + outbox=mock_outbox, + probe=mock_probe, + ) + + assert isinstance(repository._serializer, IAMEventSerializer) + + +class TestObservabilityProbe: + """Tests for domain probe usage.""" + + @pytest.mark.asyncio + async def test_probe_called_on_save( + self, repository, mock_session, mock_probe, tenant_id + ): + """Should call probe.workspace_saved on successful save.""" + workspace = Workspace.create_root( + name="Root", + tenant_id=tenant_id, + ) + + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.save(workspace) + + mock_probe.workspace_saved.assert_called_once_with( + workspace.id.value, tenant_id.value + ) + + @pytest.mark.asyncio + async def test_probe_called_on_not_found( + self, repository, mock_session, mock_probe + ): + """Should call probe.workspace_not_found when get_by_id finds nothing.""" + workspace_id = WorkspaceId.generate() + + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.get_by_id(workspace_id) + + mock_probe.workspace_not_found.assert_called_once_with( + workspace_id=workspace_id.value + ) + + @pytest.mark.asyncio + async def test_probe_called_on_get_by_name_not_found( + self, repository, mock_session, mock_probe, tenant_id + ): + """Should call probe.workspace_not_found when get_by_name finds nothing.""" + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.get_by_name(tenant_id, "Nonexistent") + + mock_probe.workspace_not_found.assert_called_once_with( + tenant_id=tenant_id.value, + name="Nonexistent", + ) + + @pytest.mark.asyncio + async def test_probe_called_on_get_root_workspace_not_found( + self, repository, mock_session, mock_probe, tenant_id + ): + """Should call probe.workspace_not_found when get_root_workspace finds nothing.""" + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + + await repository.get_root_workspace(tenant_id) + + mock_probe.workspace_not_found.assert_called_once_with( + tenant_id=tenant_id.value, + is_root=True, + ) + + @pytest.mark.asyncio + async def test_probe_called_on_retrieved( + self, repository, mock_session, mock_probe, tenant_id, now + ): + """Should call probe.workspace_retrieved on successful get_by_id.""" + workspace_id = WorkspaceId.generate() + model = WorkspaceModel( + id=workspace_id.value, + tenant_id=tenant_id.value, + name="Test", + parent_workspace_id=None, + is_root=True, + created_at=now, + updated_at=now, + ) + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = model + mock_session.execute.return_value = mock_result + + await repository.get_by_id(workspace_id) + + mock_probe.workspace_retrieved.assert_called_once_with(workspace_id.value) + + @pytest.mark.asyncio + async def test_probe_called_on_delete( + self, repository, mock_session, mock_probe, tenant_id, now + ): + """Should call probe.workspace_deleted on successful deletion.""" + workspace_id = WorkspaceId.generate() + workspace = Workspace( + id=workspace_id, + tenant_id=tenant_id, + name="To Delete", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ) + workspace.mark_for_deletion() + + model = WorkspaceModel( + id=workspace_id.value, + tenant_id=tenant_id.value, + name="To Delete", + parent_workspace_id=None, + is_root=False, + created_at=now, + updated_at=now, + ) + + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = model + mock_session.execute.return_value = mock_result + + await repository.delete(workspace) + + mock_probe.workspace_deleted.assert_called_once_with(workspace_id.value) + + @pytest.mark.asyncio + async def test_probe_called_on_list( + self, repository, mock_session, mock_probe, tenant_id + ): + """Should call probe.workspaces_listed on list_by_tenant.""" + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + mock_session.execute.return_value = mock_result + + await repository.list_by_tenant(tenant_id) + + mock_probe.workspaces_listed.assert_called_once_with(tenant_id.value, 0) diff --git a/website/src/data/env-vars.json b/website/src/data/env-vars.json index 1669891a..bb47fb9f 100644 --- a/website/src/data/env-vars.json +++ b/website/src/data/env-vars.json @@ -207,7 +207,7 @@ "IAMSettings": { "class_name": "IAMSettings", "prefix": "KARTOGRAPH_IAM_", - "doc": "IAM (Identity and Access Management) settings.\n\n Environment variables:\n KARTOGRAPH_IAM_DEFAULT_TENANT_NAME: Default tenant name for single-tenant mode (default: default)\n ", + "doc": "IAM (Identity and Access Management) settings.\n\n Environment variables:\n KARTOGRAPH_IAM_DEFAULT_TENANT_NAME: Default tenant name for single-tenant mode (default: default)\n KARTOGRAPH_IAM_DEFAULT_WORKSPACE_NAME: Default root workspace name (default: None, uses tenant name)\n ", "properties": [ { "env_var": "KARTOGRAPH_IAM_DEFAULT_TENANT_NAME", @@ -215,6 +215,13 @@ "default": "default", "required": false, "description": "Default tenant name for single-tenant mode" + }, + { + "env_var": "KARTOGRAPH_IAM_DEFAULT_WORKSPACE_NAME", + "type": "str | None", + "default": null, + "required": false, + "description": "Default root workspace name (if None, uses tenant name)" } ] },