Skip to content

Commit 30d4ccd

Browse files
authored
feat(feature_flags): vet tenant ids on override screens and pin tenant flag behaviour (#375) (#388)
Claude-Session: https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV
1 parent 0516a7d commit 30d4ccd

10 files changed

Lines changed: 310 additions & 8 deletions

File tree

‎docs/framework/multi-tenancy.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,22 @@ model refuses a name that starts with it, so no platform role (and no
211211
`RolePermission` row, and nothing `sync_admin_all_permissions` writes) can
212212
collide with a tenant role (#377).
213213

214+
## Feature flags
215+
216+
`is_flag_enabled`, `flag_enabled` and `require_flag` read the request's tenant
217+
(`request.state.tenant_id`), so a per-tenant override beats the system value,
218+
which beats the definition default. Boot hydration loads every tenant's
219+
overrides with no tenant bound — deliberately cross-tenant (the table is not
220+
`MultiTenantMixin`), so keep it that way. No tenant role holds
221+
`feature_flags.manage`; the tenant-override admin screens are platform-only.
222+
223+
Screens that take a tenant id from the URL can vet it without importing
224+
`tenants`: the module publishes `app.state.tenant_exists`, and
225+
`await simple_module_core.tenancy.tenant_exists(app, tenant_id)` answers
226+
`True`/`False`, or `None` when no module can say (the id is then accepted).
227+
`feature_flags` uses it to 404 on an unknown tenant when setting or listing
228+
overrides; clearing stays unvalidated so a stale override can still be removed.
229+
214230
## Testing
215231

216232
The `simple_module_test` plugin ships `tenant_client` (needs the `users` and

‎framework/core/simple_module_core/tenancy.py‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
from __future__ import annotations
1515

16+
from collections.abc import Awaitable, Callable
1617
from enum import StrEnum
18+
from typing import Any
1719

1820
TENANT_ROLE_PREFIX = "tenant:"
1921

@@ -46,4 +48,29 @@ def is_tenant_role(role: str) -> bool:
4648
return role.startswith(TENANT_ROLE_PREFIX)
4749

4850

49-
__all__ = ["TENANT_ROLE_PREFIX", "TenantRole", "is_tenant_role", "tenant_role"]
51+
TenantExists = Callable[[str], Awaitable[bool]]
52+
"""``async (tenant_id) -> bool``: whether a tenant with that id exists."""
53+
54+
55+
async def tenant_exists(app: Any, tenant_id: str) -> bool | None:
56+
"""Whether ``tenant_id`` names a known tenant, or ``None`` when nobody can say.
57+
58+
A module that owns tenants (``tenants``) publishes ``app.state.tenant_exists``
59+
(a :data:`TenantExists`). Platform screens that take a tenant id from the
60+
URL ask through this, so they can refuse a typo without importing that
61+
module; with none installed the answer is ``None`` and they accept the id.
62+
"""
63+
check: TenantExists | None = getattr(app.state, "tenant_exists", None)
64+
if check is None:
65+
return None
66+
return bool(await check(tenant_id))
67+
68+
69+
__all__ = [
70+
"TENANT_ROLE_PREFIX",
71+
"TenantExists",
72+
"TenantRole",
73+
"is_tenant_role",
74+
"tenant_exists",
75+
"tenant_role",
76+
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""``tenant_exists``: the soft lookup platform screens use to vet a tenant id."""
2+
3+
from __future__ import annotations
4+
5+
from types import SimpleNamespace
6+
7+
from simple_module_core.tenancy import tenant_exists
8+
9+
10+
async def test_none_when_no_module_publishes_a_directory():
11+
app = SimpleNamespace(state=SimpleNamespace())
12+
assert await tenant_exists(app, "t1") is None
13+
14+
15+
async def test_delegates_to_the_published_callable():
16+
async def exists(tenant_id: str) -> bool:
17+
return tenant_id == "t1"
18+
19+
app = SimpleNamespace(state=SimpleNamespace(tenant_exists=exists))
20+
assert await tenant_exists(app, "t1") is True
21+
assert await tenant_exists(app, "t2") is False

‎modules/feature_flags/feature_flags/deps.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
from typing import Annotated
66

7-
from fastapi import Depends, Request
7+
from fastapi import Depends, HTTPException, Request
88
from simple_module_core.feature_flags import FeatureFlagRegistry
9+
from simple_module_core.tenancy import tenant_exists
910
from simple_module_db.deps import get_db
1011
from sqlalchemy.ext.asyncio import AsyncSession
1112

@@ -23,5 +24,18 @@ def get_feature_flag_registry(request: Request) -> FeatureFlagRegistry:
2324
return request.app.state.sm.feature_flags
2425

2526

27+
async def require_known_tenant(request: Request, tenant_id: str | None = None) -> None:
28+
"""404 when a tenant-scope screen names a tenant that does not exist.
29+
30+
Without it a typo in the URL persists an override for a tenant nobody has,
31+
which nothing ever reads. The lookup goes through core's ``tenant_exists``
32+
(``app.state.tenant_exists``, published by the ``tenants`` module), so this
33+
module does not depend on ``tenants``; with none installed any id is
34+
accepted, as before. No id (system scope) is always fine.
35+
"""
36+
if tenant_id and await tenant_exists(request.app, tenant_id) is False:
37+
raise HTTPException(status_code=404, detail="Tenant not found")
38+
39+
2640
FeatureFlagServiceDep = Annotated[FeatureFlagService, Depends(get_feature_flag_service)]
2741
FeatureFlagRegistryDep = Annotated[FeatureFlagRegistry, Depends(get_feature_flag_registry)]

‎modules/feature_flags/feature_flags/endpoints/api.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
SCOPE_TENANT,
1313
)
1414
from feature_flags.contracts.schemas import FeatureFlagView, ToggleRequest
15-
from feature_flags.deps import FeatureFlagRegistryDep, FeatureFlagServiceDep
15+
from feature_flags.deps import (
16+
FeatureFlagRegistryDep,
17+
FeatureFlagServiceDep,
18+
require_known_tenant,
19+
)
1620

1721
router = APIRouter()
1822

@@ -87,7 +91,10 @@ async def clear_override(
8791
@router.get(
8892
"/tenant/{tenant_id}",
8993
response_model=list[FeatureFlagView],
90-
dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW))],
94+
dependencies=[
95+
Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW)),
96+
Depends(require_known_tenant),
97+
],
9198
)
9299
async def list_flags_for_tenant(
93100
tenant_id: str,
@@ -100,7 +107,10 @@ async def list_flags_for_tenant(
100107
@router.put(
101108
"/tenant/{tenant_id}/{name}",
102109
response_model=FeatureFlagView,
103-
dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE))],
110+
dependencies=[
111+
Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE)),
112+
Depends(require_known_tenant),
113+
],
104114
)
105115
async def set_tenant_override(
106116
tenant_id: str,
@@ -118,6 +128,8 @@ async def set_tenant_override(
118128
return view
119129

120130

131+
# Clearing is not validated: an override left behind for a tenant that has
132+
# since gone must stay removable.
121133
@router.delete(
122134
"/tenant/{tenant_id}/{name}",
123135
status_code=204,

‎modules/feature_flags/feature_flags/endpoints/views.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@
2626
SCOPE_TENANT,
2727
SYSTEM_SCOPE_ID,
2828
)
29-
from feature_flags.deps import FeatureFlagRegistryDep, FeatureFlagServiceDep
29+
from feature_flags.deps import (
30+
FeatureFlagRegistryDep,
31+
FeatureFlagServiceDep,
32+
require_known_tenant,
33+
)
3034

3135
router = APIRouter()
3236

@@ -61,7 +65,10 @@ def _scope_args(tenant_id: str | None) -> dict[str, str]:
6165
@router.get(
6266
"/",
6367
response_model=None,
64-
dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW))],
68+
dependencies=[
69+
Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW)),
70+
Depends(require_known_tenant),
71+
],
6572
)
6673
async def browse(
6774
request: Request,
@@ -86,7 +93,10 @@ async def browse(
8693
@router.post(
8794
"/{name}/toggle",
8895
response_model=None,
89-
dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE))],
96+
dependencies=[
97+
Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE)),
98+
Depends(require_known_tenant),
99+
],
90100
)
91101
async def toggle_action(
92102
name: str,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Fixtures for tenant-scope flag tests, which need real tenants to name."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
8+
@pytest.fixture
9+
def make_tenant(app):
10+
"""``await make_tenant("acme")`` — a tenants row with that id."""
11+
12+
async def make(tenant_id: str) -> str:
13+
from tenants.models import Tenant
14+
15+
async with app.state.sm.db.session_factory() as session:
16+
session.add(Tenant(id=tenant_id, slug=tenant_id, name=tenant_id.title()))
17+
await session.commit()
18+
return tenant_id
19+
20+
return make
21+
22+
23+
@pytest.fixture
24+
async def acme_tenant(make_tenant) -> str:
25+
return await make_tenant("acme")

‎modules/feature_flags/tests/test_feature_flags_api.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import httpx
6+
import pytest
67

78

89
class TestFeatureFlagsAPI:
@@ -58,6 +59,7 @@ async def test_get_flag_returns_view(self, authenticated_client: httpx.AsyncClie
5859
assert "overridden" in body
5960

6061

62+
@pytest.mark.usefixtures("acme_tenant")
6163
class TestFeatureFlagsTenantAPI:
6264
async def test_set_tenant_override_creates_tenant_specific_row(
6365
self, authenticated_client: httpx.AsyncClient

0 commit comments

Comments
 (0)