From 2e5ffd8753f2c6bb23ad459c84326b13574146e7 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 21 Sep 2026 17:18:02 +0100 Subject: [PATCH 1/6] test(tenancy): pin the scope lookups organizations answers for other domains Organizations should answer which organization owns a workspace or a membership, given only its ID, so a domain that stores bare scope IDs never queries the tenancy tables itself. These tests fail until OrganizationService offers the seven read-only lookups. --- .../test_organization_scope_lookups.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/integration/test_organization_scope_lookups.py diff --git a/tests/integration/test_organization_scope_lookups.py b/tests/integration/test_organization_scope_lookups.py new file mode 100644 index 0000000000..4551618379 --- /dev/null +++ b/tests/integration/test_organization_scope_lookups.py @@ -0,0 +1,159 @@ +"""Organizations answers which organization owns a workspace or a membership, and lists the IDs it holds.""" + +import uuid +from dataclasses import dataclass + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.models.tenancy import Organization, OrganizationMember, User, Workspace, WorkspaceMember +from gateway.repositories.tenancy import ( + OrganizationMemberRepository, + UserRepository, + WorkspaceMemberRepository, + WorkspaceRepository, +) +from gateway.services.tenancy.organization_service import OrganizationService + +from .tenancy_helpers import create_member, create_organization, create_workspace + +pytestmark = pytest.mark.asyncio + + +@dataclass(frozen=True) +class _Tenant: + organization: Organization + owner: User + workspace: Workspace + organization_member: OrganizationMember + workspace_member: WorkspaceMember + + +async def _tenant(db: AsyncSession, slug: str) -> _Tenant: + organization = await create_organization(db, slug=slug) + owner = await create_member(db, organization, role="owner", full_name=f"{slug} owner") + workspace = await create_workspace(db, organization, name=f"{slug} workspace", owner=owner) + organization_member = await OrganizationMemberRepository(db).get_by_organization_and_user(organization.id, owner.id) + workspace_member = await WorkspaceMemberRepository(db).get_by_workspace_and_user(workspace.id, owner.id) + assert organization_member is not None + assert workspace_member is not None + return _Tenant(organization, owner, workspace, organization_member, workspace_member) + + +async def _identity(db: AsyncSession, organization: Organization) -> User: + return await UserRepository(db).create_local_identity(full_name="Leaver", active_organization_id=organization.id) + + +def _service(db: AsyncSession) -> OrganizationService: + return OrganizationService(db, membership_listener=None) + + +async def test_a_workspace_resolves_to_its_organization(async_db: AsyncSession) -> None: + acme = await _tenant(async_db, "acme") + globex = await _tenant(async_db, "globex") + service = _service(async_db) + + assert await service.get_organization_id_for_workspace(acme.workspace.id) == acme.organization.id + assert await service.get_organization_id_for_workspace(globex.workspace.id) == globex.organization.id + assert await service.get_organization_id_for_workspace(uuid.uuid4()) is None + + +async def test_a_deleted_workspace_resolves_to_no_organization(async_db: AsyncSession) -> None: + """A deleted workspace has no organization, even after an earlier lookup found one.""" + acme = await _tenant(async_db, "acme") + service = _service(async_db) + workspace_id = acme.workspace.id + assert await service.get_organization_id_for_workspace(workspace_id) == acme.organization.id + + await WorkspaceRepository(async_db).delete_workspace(acme.workspace) + + assert await service.get_organization_id_for_workspace(workspace_id) is None + + +async def test_an_organization_membership_resolves_to_its_organization(async_db: AsyncSession) -> None: + acme = await _tenant(async_db, "acme") + globex = await _tenant(async_db, "globex") + service = _service(async_db) + + assert ( + await service.get_organization_id_for_organization_member(acme.organization_member.id) + == acme.organization.id + ) + assert ( + await service.get_organization_id_for_organization_member(globex.organization_member.id) + == globex.organization.id + ) + assert await service.get_organization_id_for_organization_member(uuid.uuid4()) is None + + +async def test_a_workspace_membership_resolves_to_its_workspace(async_db: AsyncSession) -> None: + acme = await _tenant(async_db, "acme") + globex = await _tenant(async_db, "globex") + service = _service(async_db) + + assert await service.get_workspace_id_for_workspace_member(acme.workspace_member.id) == acme.workspace.id + assert await service.get_workspace_id_for_workspace_member(globex.workspace_member.id) == globex.workspace.id + assert await service.get_workspace_id_for_workspace_member(uuid.uuid4()) is None + + +async def test_only_an_existing_organization_is_found(async_db: AsyncSession) -> None: + acme = await _tenant(async_db, "acme") + service = _service(async_db) + + assert await service.has_organization(acme.organization.id) is True + assert await service.has_organization(uuid.uuid4()) is False + + +async def test_an_organization_lists_only_its_own_workspaces(async_db: AsyncSession) -> None: + acme = await _tenant(async_db, "acme") + globex = await _tenant(async_db, "globex") + second = await create_workspace(async_db, acme.organization, name="acme second", owner=acme.owner) + service = _service(async_db) + + assert sorted(await service.get_workspace_ids_in_organization(acme.organization.id)) == sorted( + [acme.workspace.id, second.id] + ) + assert await service.get_workspace_ids_in_organization(globex.organization.id) == [globex.workspace.id] + assert await service.get_workspace_ids_in_organization(uuid.uuid4()) == [] + + +async def test_an_organization_lists_every_membership_whatever_its_status(async_db: AsyncSession) -> None: + acme = await _tenant(async_db, "acme") + globex = await _tenant(async_db, "globex") + leaver = await _identity(async_db, acme.organization) + suspended = await OrganizationMemberRepository(async_db).create_membership( + organization_id=acme.organization.id, + user_id=leaver.id, + role="member", + status="suspended", + ) + service = _service(async_db) + + assert sorted(await service.get_organization_member_ids(acme.organization.id)) == sorted( + [acme.organization_member.id, suspended.id] + ) + assert await service.get_organization_member_ids(globex.organization.id) == [globex.organization_member.id] + assert await service.get_organization_member_ids(uuid.uuid4()) == [] + + +async def test_an_organization_lists_every_workspace_membership_whatever_its_status(async_db: AsyncSession) -> None: + acme = await _tenant(async_db, "acme") + globex = await _tenant(async_db, "globex") + second = await create_workspace(async_db, acme.organization, name="acme second", owner=acme.owner) + second_owner = await WorkspaceMemberRepository(async_db).get_by_workspace_and_user(second.id, acme.owner.id) + assert second_owner is not None + leaver = await _identity(async_db, acme.organization) + suspended = await WorkspaceMemberRepository(async_db).create( + workspace_id=acme.workspace.id, + user_id=leaver.id, + status="suspended", + ) + service = _service(async_db) + + assert sorted(await service.get_workspace_member_ids_in_organization(acme.organization.id)) == sorted( + [acme.workspace_member.id, second_owner.id, suspended.id] + ) + assert await service.get_workspace_member_ids_in_organization(globex.organization.id) == [ + globex.workspace_member.id + ] + assert await service.get_workspace_member_ids_in_organization(uuid.uuid4()) == [] From bdb2e28ec7039c3f7b36f3473412a4ba20d13604 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 21 Sep 2026 17:19:36 +0100 Subject: [PATCH 2/6] feat(tenancy): answer whether an organization exists and which workspaces it holds Both answers come from repository methods that already exist, so this step adds no query. --- src/gateway/services/tenancy/organization_service.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/gateway/services/tenancy/organization_service.py b/src/gateway/services/tenancy/organization_service.py index e00abf65c4..c20824fac2 100644 --- a/src/gateway/services/tenancy/organization_service.py +++ b/src/gateway/services/tenancy/organization_service.py @@ -268,6 +268,14 @@ async def user_has_active_membership(self, *, organization_id: uuid.UUID, user_i """Whether an identity is an active member of an organization.""" return await self.members.get_active_by_organization_and_user(organization_id, user_id) is not None + async def has_organization(self, organization_id: uuid.UUID) -> bool: + """Return whether an organization with this ID exists.""" + return await self.organizations.get(organization_id) is not None + + async def get_workspace_ids_in_organization(self, organization_id: uuid.UUID) -> list[uuid.UUID]: + """Return the ID of every workspace in an organization.""" + return await self.workspace_rows.get_ids_by_organization(organization_id) + async def _to_context( self, *, From 5c9f667c8190c2069ee9cc7d40be2216d1048823 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 21 Sep 2026 17:20:36 +0100 Subject: [PATCH 3/6] feat(tenancy): answer which organization owns a workspace The lookup reads the caller's session on every call. The memoized organization_for_workspace_id in workspace_scope does not fit an ownership check: it keeps answering a deleted workspace's old organization, so a check built on it would accept a scope that no longer exists. --- src/gateway/repositories/tenancy/workspace_repository.py | 7 +++++++ src/gateway/services/tenancy/organization_service.py | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/gateway/repositories/tenancy/workspace_repository.py b/src/gateway/repositories/tenancy/workspace_repository.py index 3404cf928a..8d0ecaae17 100644 --- a/src/gateway/repositories/tenancy/workspace_repository.py +++ b/src/gateway/repositories/tenancy/workspace_repository.py @@ -101,6 +101,13 @@ async def get_ids_by_organization(self, organization_id: uuid.UUID) -> list[uuid ) return list(result.scalars().all()) + async def get_organization_id(self, workspace_id: uuid.UUID) -> uuid.UUID | None: + """Return the ID of the organization that owns a workspace, or None.""" + result = await self.db.execute( + select(col(Workspace.organization_id)).where(col(Workspace.id) == workspace_id) + ) + return result.scalar_one_or_none() + async def get_by_organization_and_name(self, organization_id: uuid.UUID, name: str) -> Workspace | None: """Return an organization's workspace with this name, or None.""" result = await self.db.execute( diff --git a/src/gateway/services/tenancy/organization_service.py b/src/gateway/services/tenancy/organization_service.py index c20824fac2..160537eb15 100644 --- a/src/gateway/services/tenancy/organization_service.py +++ b/src/gateway/services/tenancy/organization_service.py @@ -272,6 +272,10 @@ async def has_organization(self, organization_id: uuid.UUID) -> bool: """Return whether an organization with this ID exists.""" return await self.organizations.get(organization_id) is not None + async def get_organization_id_for_workspace(self, workspace_id: uuid.UUID) -> uuid.UUID | None: + """Return the ID of the organization that owns a workspace, or None when the workspace does not exist.""" + return await self.workspace_rows.get_organization_id(workspace_id) + async def get_workspace_ids_in_organization(self, organization_id: uuid.UUID) -> list[uuid.UUID]: """Return the ID of every workspace in an organization.""" return await self.workspace_rows.get_ids_by_organization(organization_id) From 9a7e6dc2e8c4222c1840d8256b2dfae11f07f4ba Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 21 Sep 2026 17:21:05 +0100 Subject: [PATCH 4/6] feat(tenancy): answer which organization owns an organization membership The member list keeps every status, so a scope on a suspended membership still resolves to its organization. --- .../tenancy/organization_member_repository.py | 14 ++++++++++++++ .../services/tenancy/organization_service.py | 11 +++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/gateway/repositories/tenancy/organization_member_repository.py b/src/gateway/repositories/tenancy/organization_member_repository.py index 1017a4ba45..a20397634a 100644 --- a/src/gateway/repositories/tenancy/organization_member_repository.py +++ b/src/gateway/repositories/tenancy/organization_member_repository.py @@ -147,6 +147,20 @@ async def get_first_active_for_user(self, user_id: uuid.UUID) -> OrganizationMem ) return result.scalars().first() + async def get_organization_id(self, organization_member_id: uuid.UUID) -> uuid.UUID | None: + """Return the ID of the organization a membership belongs to, or None.""" + result = await self.db.execute( + select(col(OrganizationMember.organization_id)).where(col(OrganizationMember.id) == organization_member_id) + ) + return result.scalar_one_or_none() + + async def get_ids_by_organization(self, organization_id: uuid.UUID) -> list[uuid.UUID]: + """Return the ID of every membership in an organization, whatever its status.""" + result = await self.db.execute( + select(col(OrganizationMember.id)).where(col(OrganizationMember.organization_id) == organization_id) + ) + return list(result.scalars().all()) + async def create_membership( self, *, diff --git a/src/gateway/services/tenancy/organization_service.py b/src/gateway/services/tenancy/organization_service.py index 160537eb15..38a6a4ec72 100644 --- a/src/gateway/services/tenancy/organization_service.py +++ b/src/gateway/services/tenancy/organization_service.py @@ -276,10 +276,21 @@ async def get_organization_id_for_workspace(self, workspace_id: uuid.UUID) -> uu """Return the ID of the organization that owns a workspace, or None when the workspace does not exist.""" return await self.workspace_rows.get_organization_id(workspace_id) + async def get_organization_id_for_organization_member( + self, + organization_member_id: uuid.UUID, + ) -> uuid.UUID | None: + """Return the ID of the organization a membership belongs to, or None when the membership does not exist.""" + return await self.members.get_organization_id(organization_member_id) + async def get_workspace_ids_in_organization(self, organization_id: uuid.UUID) -> list[uuid.UUID]: """Return the ID of every workspace in an organization.""" return await self.workspace_rows.get_ids_by_organization(organization_id) + async def get_organization_member_ids(self, organization_id: uuid.UUID) -> list[uuid.UUID]: + """Return the ID of every membership in an organization, whatever its status.""" + return await self.members.get_ids_by_organization(organization_id) + async def _to_context( self, *, From 005f5d4d202188d5813f99e2d42752d6e6509494 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 21 Sep 2026 17:21:43 +0100 Subject: [PATCH 5/6] feat(tenancy): answer which workspace owns a workspace membership The organization-wide membership list joins workspace in one query rather than reading the workspace IDs first. It keeps every status, as the organization member list does. The WorkspaceMemberRepository docstring loses its claim that every access is keyed by the (workspace, user) pair. The lookup by membership ID makes it false. --- .../tenancy/workspace_repository.py | 22 ++++++++++++++----- .../services/tenancy/organization_service.py | 8 +++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/gateway/repositories/tenancy/workspace_repository.py b/src/gateway/repositories/tenancy/workspace_repository.py index 8d0ecaae17..8e1c373e25 100644 --- a/src/gateway/repositories/tenancy/workspace_repository.py +++ b/src/gateway/repositories/tenancy/workspace_repository.py @@ -153,11 +153,7 @@ async def delete_workspace(self, workspace: Workspace) -> None: class WorkspaceMemberRepository: - """Repository for workspace membership rows. - - Not a ``BaseRepository``: every access is keyed by the (workspace, user) - pair rather than by the row's own id, so none of the generic helpers apply. - """ + """Repository for workspace membership rows.""" def __init__(self, db: AsyncSession): self.db = db @@ -314,6 +310,22 @@ async def ids_for_workspace(self, workspace_id: uuid.UUID) -> list[uuid.UUID]: ) return list(result.scalars().all()) + async def get_workspace_id(self, workspace_member_id: uuid.UUID) -> uuid.UUID | None: + """Return the ID of the workspace a membership belongs to, or None.""" + result = await self.db.execute( + select(col(WorkspaceMember.workspace_id)).where(col(WorkspaceMember.id) == workspace_member_id) + ) + return result.scalar_one_or_none() + + async def get_ids_by_organization(self, organization_id: uuid.UUID) -> list[uuid.UUID]: + """Return the ID of every membership in an organization's workspaces, whatever its status.""" + result = await self.db.execute( + select(col(WorkspaceMember.id)) + .join(Workspace, col(Workspace.id) == col(WorkspaceMember.workspace_id)) + .where(col(Workspace.organization_id) == organization_id) + ) + return list(result.scalars().all()) + async def create( self, *, diff --git a/src/gateway/services/tenancy/organization_service.py b/src/gateway/services/tenancy/organization_service.py index 38a6a4ec72..b97bb40f4a 100644 --- a/src/gateway/services/tenancy/organization_service.py +++ b/src/gateway/services/tenancy/organization_service.py @@ -283,6 +283,10 @@ async def get_organization_id_for_organization_member( """Return the ID of the organization a membership belongs to, or None when the membership does not exist.""" return await self.members.get_organization_id(organization_member_id) + async def get_workspace_id_for_workspace_member(self, workspace_member_id: uuid.UUID) -> uuid.UUID | None: + """Return the ID of the workspace a membership belongs to, or None when the membership does not exist.""" + return await self.workspaces.get_workspace_id(workspace_member_id) + async def get_workspace_ids_in_organization(self, organization_id: uuid.UUID) -> list[uuid.UUID]: """Return the ID of every workspace in an organization.""" return await self.workspace_rows.get_ids_by_organization(organization_id) @@ -291,6 +295,10 @@ async def get_organization_member_ids(self, organization_id: uuid.UUID) -> list[ """Return the ID of every membership in an organization, whatever its status.""" return await self.members.get_ids_by_organization(organization_id) + async def get_workspace_member_ids_in_organization(self, organization_id: uuid.UUID) -> list[uuid.UUID]: + """Return the ID of every membership in an organization's workspaces, whatever its status.""" + return await self.workspaces.get_ids_by_organization(organization_id) + async def _to_context( self, *, From 5deedf96701dbb8ae2672aafc56337789ec6fc5a Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 21 Sep 2026 18:38:29 +0100 Subject: [PATCH 6/6] docs(tenancy): state the organization service's tenant rule so it holds for every method The module docstring said every method but one resolves the organization from the caller alone. The seven ID lookups take no caller, the invitation token methods have none, and accepting or declining a pending membership reaches an organization that is not the active one. The narrowed rule covers what those methods share: a method acting for a caller never trusts an organization the request names, and another tenant's ID answers not-found. The docstring also cited private-repository issues and recounted how the service arrived. Both are cut. --- .../services/tenancy/organization_service.py | 41 ++++--------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/src/gateway/services/tenancy/organization_service.py b/src/gateway/services/tenancy/organization_service.py index b97bb40f4a..83df306299 100644 --- a/src/gateway/services/tenancy/organization_service.py +++ b/src/gateway/services/tenancy/organization_service.py @@ -1,35 +1,12 @@ -"""Organizations: active-organization resolution, CRUD, and membership. - -Rehomed from the platform's ``OrganizationService`` plus the membership half of -``OrganizationMembershipService``, converted to async. The authorization rules, -the membership constraints, and the response shapes are the platform's; what is -gone is the depth that has no home in the OSS base yet: mixpanel tracking, -managed provider-key and default-gateway provisioning, email-domain auto-join, -teams, and the org's budget and pricing surfaces. Those arrive with their own -slices, tracked under mozilla-ai/otari-ai#1452, and this service is where they -attach. Emailed invitations shipped here in mozilla-ai/otari#641 (see -``invite_active_organization_member_for_user``/``accept_invitation`` below); -``create_active_organization_member_for_user`` is the older, still-supported -immediate path this replaced no part of. - -One rule runs through every method: a caller only ever acts inside the -organization their identity is currently pointed at, and every method but one -resolves that organization from the caller alone rather than from the request. -The exception is ``switch_active_organization_for_user``, which is the method -that *moves* the pointer and so has to be told where to; it answers not-found -for an id the caller holds no active membership in, so naming another tenant's -organization tells the caller nothing about it. - -A standalone deployment still *boots* one organization, provisioned at first -boot, and that is the shape almost every deployment keeps. But a second one is -reachable (accept an invitation into an organization elsewhere on the same -deployment and you hold two memberships), so creating one, listing the ones you -belong to, and switching between them are part of this surface rather than an -overlay's: the tables are here, the invariants that decide who becomes owner -and what happens to ``active_organization_id`` are here, and an overlay that -contributes no tables could only fork them (mozilla-ai/otari#715). Deleting an -organization is still absent, which is a separate question: the rows every -historical attribution resolves through hang off it. +"""This module resolves a caller's active organization and manages organizations and their members. + +A method that acts for a caller never trusts an organization that the request names. +It acts in the caller's active organization, or in one it reaches through the caller's own membership. +An ID that names another tenant's row answers not-found, so a caller cannot probe which IDs exist. + +A deployment can hold more than one organization. +Creating, listing and switching organizations therefore belong here rather than in an overlay. +The service offers no way to delete an organization, because historical attribution resolves through its rows. """ import hashlib