victor34593993 commited on
Commit
7b0751b
·
verified ·
1 Parent(s): 3acfe6c

enforce unique tenant resolution keys at every write (clean 409)

Browse files
Files changed (3) hide show
  1. app/routes/admin.py +2 -6
  2. app/tenancy.py +30 -0
  3. tests/test_multitenant.py +23 -0
app/routes/admin.py CHANGED
@@ -185,12 +185,8 @@ async def get_tenants(db: AsyncSession = Depends(get_session)) -> list[TenantOut
185
  async def post_tenant(body: TenantIn, db: AsyncSession = Depends(get_session)) -> TenantOut:
186
  if await get_tenant_by_slug(db, body.slug) is not None:
187
  raise HTTPException(status_code=409, detail="slug already exists")
188
- # A shop maps to exactly one tenant (every channel resolves by it). Refuse to
189
- # create a second row for a store another tenant already owns.
190
- if body.shopify_shop and (
191
- await db.execute(select(Tenant).where(Tenant.shopify_shop == body.shopify_shop))
192
- ).scalar_one_or_none() is not None:
193
- raise HTTPException(status_code=409, detail="shopify_shop already assigned to another tenant")
194
  fields = body.model_dump(
195
  exclude={"slug", "shopify_client_secret", "whatsapp_token"}, exclude_none=True
196
  )
 
185
  async def post_tenant(body: TenantIn, db: AsyncSession = Depends(get_session)) -> TenantOut:
186
  if await get_tenant_by_slug(db, body.slug) is not None:
187
  raise HTTPException(status_code=409, detail="slug already exists")
188
+ # Uniqueness of shopify_shop / whatsapp_phone_id is enforced centrally in
189
+ # create_tenant (clean 409 if another tenant owns the value).
 
 
 
 
190
  fields = body.model_dump(
191
  exclude={"slug", "shopify_client_secret", "whatsapp_token"}, exclude_none=True
192
  )
app/tenancy.py CHANGED
@@ -9,6 +9,7 @@ from __future__ import annotations
9
 
10
  from typing import Any
11
 
 
12
  from sqlalchemy import delete, select
13
  from sqlalchemy.ext.asyncio import AsyncSession
14
 
@@ -76,6 +77,31 @@ _TENANT_FIELDS = {
76
  }
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  async def create_tenant(
80
  db: AsyncSession,
81
  slug: str,
@@ -85,6 +111,7 @@ async def create_tenant(
85
  **fields: Any,
86
  ) -> Tenant:
87
  data = {k: v for k, v in fields.items() if k in _TENANT_FIELDS and v is not None}
 
88
  tenant = Tenant(slug=slug, **data)
89
  if shopify_client_secret:
90
  tenant.shopify_client_secret_enc = encrypt(shopify_client_secret)
@@ -104,6 +131,9 @@ async def update_tenant(
104
  whatsapp_token: str | None = None,
105
  **fields: Any,
106
  ) -> Tenant:
 
 
 
107
  for k, v in fields.items():
108
  if k in _TENANT_FIELDS and v is not None:
109
  setattr(tenant, k, v)
 
9
 
10
  from typing import Any
11
 
12
+ from fastapi import HTTPException
13
  from sqlalchemy import delete, select
14
  from sqlalchemy.ext.asyncio import AsyncSession
15
 
 
77
  }
78
 
79
 
80
+ # A shop / WhatsApp number / Stripe customer maps to EXACTLY ONE tenant. These keys
81
+ # are how inbound requests resolve a client, so a duplicate would mix two accounts.
82
+ _UNIQUE_TENANT_KEYS = ("shopify_shop", "whatsapp_phone_id", "stripe_customer_id")
83
+
84
+
85
+ async def _assert_unique_keys(
86
+ db: AsyncSession, fields: dict[str, Any], *, exclude_id: int | None = None
87
+ ) -> None:
88
+ """Refuse to assign a shop/phone/customer-id another tenant already owns — a
89
+ clean 409 at write time instead of a later MultipleResultsFound 500 (empty
90
+ values never collide). The partial-unique DB indexes (migration 0027) are the
91
+ backstop; this is the friendly, early guard at every create/edit path."""
92
+ for key in _UNIQUE_TENANT_KEYS:
93
+ val = fields.get(key)
94
+ if not val:
95
+ continue
96
+ stmt = select(Tenant.id).where(getattr(Tenant, key) == val)
97
+ if exclude_id is not None:
98
+ stmt = stmt.where(Tenant.id != exclude_id)
99
+ if (await db.execute(stmt)).first() is not None:
100
+ raise HTTPException(
101
+ status_code=409, detail=f"{key} already assigned to another tenant"
102
+ )
103
+
104
+
105
  async def create_tenant(
106
  db: AsyncSession,
107
  slug: str,
 
111
  **fields: Any,
112
  ) -> Tenant:
113
  data = {k: v for k, v in fields.items() if k in _TENANT_FIELDS and v is not None}
114
+ await _assert_unique_keys(db, data)
115
  tenant = Tenant(slug=slug, **data)
116
  if shopify_client_secret:
117
  tenant.shopify_client_secret_enc = encrypt(shopify_client_secret)
 
131
  whatsapp_token: str | None = None,
132
  **fields: Any,
133
  ) -> Tenant:
134
+ await _assert_unique_keys(
135
+ db, {k: v for k, v in fields.items() if k in _TENANT_FIELDS}, exclude_id=tenant.id
136
+ )
137
  for k, v in fields.items():
138
  if k in _TENANT_FIELDS and v is not None:
139
  setattr(tenant, k, v)
tests/test_multitenant.py CHANGED
@@ -122,3 +122,26 @@ async def test_get_tenant_by_slug(db_session):
122
  await db_session.flush()
123
  assert (await get_tenant_by_slug(db_session, "toorx")).slug == "toorx"
124
  assert await get_tenant_by_slug(db_session, "missing") is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  await db_session.flush()
123
  assert (await get_tenant_by_slug(db_session, "toorx")).slug == "toorx"
124
  assert await get_tenant_by_slug(db_session, "missing") is None
125
+
126
+
127
+ async def test_resolution_keys_unique_clean_409(db_session):
128
+ """shopify_shop / whatsapp_phone_id map to exactly ONE tenant: a duplicate is a
129
+ clean 409 at write time, never two rows that would mix two client accounts."""
130
+ from fastapi import HTTPException
131
+
132
+ from app.tenancy import update_tenant
133
+
134
+ a = await create_tenant(db_session, "shop-a", shopify_shop="a.myshopify.com")
135
+ b = await create_tenant(db_session, "shop-b", shopify_shop="b.myshopify.com")
136
+ with pytest.raises(HTTPException) as e1: # new tenant grabbing A's shop
137
+ await create_tenant(db_session, "shop-a-dup", shopify_shop="a.myshopify.com")
138
+ assert e1.value.status_code == 409
139
+ with pytest.raises(HTTPException) as e2: # B editing onto A's shop
140
+ await update_tenant(db_session, b, shopify_shop="a.myshopify.com")
141
+ assert e2.value.status_code == 409
142
+ await update_tenant(db_session, a, shopify_shop="a.myshopify.com") # own value -> ok
143
+ await update_tenant(db_session, a, whatsapp_phone_id="PHONE1")
144
+ with pytest.raises(HTTPException) as e3: # B grabbing A's WhatsApp number
145
+ await update_tenant(db_session, b, whatsapp_phone_id="PHONE1")
146
+ assert e3.value.status_code == 409
147
+ await update_tenant(db_session, b, whatsapp_phone_id="") # empty never collides