simple-module-feature-flags 0.0.35__tar.gz → 0.0.37__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/.gitignore +1 -0
  2. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/PKG-INFO +4 -4
  3. simple_module_feature_flags-0.0.37/feature_flags/deps.py +41 -0
  4. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/endpoints/api.py +15 -3
  5. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/endpoints/views.py +13 -3
  6. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/pyproject.toml +4 -4
  7. simple_module_feature_flags-0.0.37/tests/conftest.py +25 -0
  8. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/tests/test_feature_flags_api.py +2 -0
  9. simple_module_feature_flags-0.0.37/tests/test_feature_flags_tenancy.py +162 -0
  10. simple_module_feature_flags-0.0.35/feature_flags/deps.py +0 -27
  11. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/LICENSE +0 -0
  12. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/README.md +0 -0
  13. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/__init__.py +0 -0
  14. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/constants.py +0 -0
  15. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/contracts/__init__.py +0 -0
  16. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/contracts/schemas.py +0 -0
  17. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/endpoints/__init__.py +0 -0
  18. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/locales/en.json +0 -0
  19. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/models.py +0 -0
  20. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/module.py +0 -0
  21. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/pages/Browse.tsx +0 -0
  22. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/pages/components/FlagTable.tsx +0 -0
  23. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/pages/components/ScopeCard.tsx +0 -0
  24. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/pages/components/TenantPicker.tsx +0 -0
  25. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/pages/components/ToggleConfirmDialog.tsx +0 -0
  26. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/pages/types.ts +0 -0
  27. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/py.typed +0 -0
  28. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/feature_flags/service.py +0 -0
  29. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/package.json +0 -0
  30. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/tests/test_feature_flags_audit_links.py +0 -0
  31. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/tests/test_feature_flags_menu_section.py +0 -0
  32. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/tests/test_feature_flags_service.py +0 -0
  33. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/tests/test_store_outage_fallback.py +0 -0
  34. {simple_module_feature_flags-0.0.35 → simple_module_feature_flags-0.0.37}/tsconfig.json +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_feature_flags
3
- Version: 0.0.35
3
+ Version: 0.0.37
4
4
  Summary: Simple feature-flag module with per-tenant overrides and a consumer API for simple_module
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: simple-module-core==0.0.35
25
- Requires-Dist: simple-module-db==0.0.35
26
- Requires-Dist: simple-module-hosting==0.0.35
24
+ Requires-Dist: simple-module-core==0.0.37
25
+ Requires-Dist: simple-module-db==0.0.37
26
+ Requires-Dist: simple-module-hosting==0.0.37
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # simple_module_feature_flags
@@ -0,0 +1,41 @@
1
+ """FastAPI dependencies for the feature_flags module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends, HTTPException, Request
8
+ from simple_module_core.feature_flags import FeatureFlagRegistry
9
+ from simple_module_core.tenancy import tenant_exists
10
+ from simple_module_db.deps import get_db
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from feature_flags.service import FeatureFlagService
14
+
15
+
16
+ async def get_feature_flag_service(
17
+ db: AsyncSession = Depends(get_db),
18
+ ) -> FeatureFlagService:
19
+ return FeatureFlagService(db)
20
+
21
+
22
+ async def get_feature_flag_registry(request: Request) -> FeatureFlagRegistry:
23
+ """Return the process-wide FeatureFlagRegistry owned by the framework."""
24
+ return request.app.state.sm.feature_flags
25
+
26
+
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
+
40
+ FeatureFlagServiceDep = Annotated[FeatureFlagService, Depends(get_feature_flag_service)]
41
+ FeatureFlagRegistryDep = Annotated[FeatureFlagRegistry, Depends(get_feature_flag_registry)]
@@ -12,7 +12,11 @@ from feature_flags.constants import (
12
12
  SCOPE_TENANT,
13
13
  )
14
14
  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
+ )
16
20
 
17
21
  router = APIRouter()
18
22
 
@@ -87,7 +91,10 @@ async def clear_override(
87
91
  @router.get(
88
92
  "/tenant/{tenant_id}",
89
93
  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
+ ],
91
98
  )
92
99
  async def list_flags_for_tenant(
93
100
  tenant_id: str,
@@ -100,7 +107,10 @@ async def list_flags_for_tenant(
100
107
  @router.put(
101
108
  "/tenant/{tenant_id}/{name}",
102
109
  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
+ ],
104
114
  )
105
115
  async def set_tenant_override(
106
116
  tenant_id: str,
@@ -118,6 +128,8 @@ async def set_tenant_override(
118
128
  return view
119
129
 
120
130
 
131
+ # Clearing is not validated: an override left behind for a tenant that has
132
+ # since gone must stay removable.
121
133
  @router.delete(
122
134
  "/tenant/{tenant_id}/{name}",
123
135
  status_code=204,
@@ -26,7 +26,11 @@ from feature_flags.constants import (
26
26
  SCOPE_TENANT,
27
27
  SYSTEM_SCOPE_ID,
28
28
  )
29
- from feature_flags.deps import FeatureFlagRegistryDep, FeatureFlagServiceDep
29
+ from feature_flags.deps import (
30
+ FeatureFlagRegistryDep,
31
+ FeatureFlagServiceDep,
32
+ require_known_tenant,
33
+ )
30
34
 
31
35
  router = APIRouter()
32
36
 
@@ -61,7 +65,10 @@ def _scope_args(tenant_id: str | None) -> dict[str, str]:
61
65
  @router.get(
62
66
  "/",
63
67
  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
+ ],
65
72
  )
66
73
  async def browse(
67
74
  request: Request,
@@ -86,7 +93,10 @@ async def browse(
86
93
  @router.post(
87
94
  "/{name}/toggle",
88
95
  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
+ ],
90
100
  )
91
101
  async def toggle_action(
92
102
  name: str,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_feature_flags"
3
- version = "0.0.35"
3
+ version = "0.0.37"
4
4
  description = "Simple feature-flag module with per-tenant overrides and a consumer API for simple_module"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,9 +21,9 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.35",
25
- "simple_module_db==0.0.35",
26
- "simple_module_hosting==0.0.35",
24
+ "simple_module_core==0.0.37",
25
+ "simple_module_db==0.0.37",
26
+ "simple_module_hosting==0.0.37",
27
27
  ]
28
28
 
29
29
  [project.entry-points.simple_module]
@@ -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")
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import httpx
6
+ import pytest
6
7
 
7
8
 
8
9
  class TestFeatureFlagsAPI:
@@ -58,6 +59,7 @@ class TestFeatureFlagsAPI:
58
59
  assert "overridden" in body
59
60
 
60
61
 
62
+ @pytest.mark.usefixtures("acme_tenant")
61
63
  class TestFeatureFlagsTenantAPI:
62
64
  async def test_set_tenant_override_creates_tenant_specific_row(
63
65
  self, authenticated_client: httpx.AsyncClient
@@ -0,0 +1,162 @@
1
+ """Feature flags under multi-tenancy (#375).
2
+
3
+ Request-time checks (``require_flag`` / ``is_flag_enabled``) already resolve
4
+ ``request.state.tenant_id``; boot hydration is deliberately cross-tenant; the
5
+ admin tenant-override endpoints refuse a tenant that does not exist.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+ from fastapi import Depends
12
+ from feature_flags.constants import PERM_FEATURE_FLAGS_MANAGE, PERM_FEATURE_FLAGS_VIEW
13
+ from feature_flags.service import FeatureFlagService
14
+ from simple_module_core.feature_flags import FeatureFlagRegistry, require_flag
15
+ from simple_module_core.tenancy import TenantRole, tenant_role
16
+
17
+ FLAG = "file_storage.public_uploads" # default off
18
+ SYSTEM = f"/api/feature_flags/{FLAG}"
19
+
20
+
21
+ def tenant_url(tenant_id: str) -> str:
22
+ return f"/api/feature_flags/tenant/{tenant_id}/{FLAG}"
23
+
24
+
25
+ @pytest.fixture
26
+ def gated(app):
27
+ """``GET /__gated`` — 200 when ``FLAG`` is on for the caller's tenant, else 404."""
28
+
29
+ @app.get("/__gated", dependencies=[Depends(require_flag(FLAG))])
30
+ async def _gated() -> dict:
31
+ return {"ok": True}
32
+
33
+ return "/__gated"
34
+
35
+
36
+ async def _put(client, url, enabled):
37
+ resp = await client.put(url, json={"enabled": enabled})
38
+ assert resp.status_code == 200, resp.text
39
+
40
+
41
+ class TestRequireFlagResolvesTheRequestTenant:
42
+ async def test_two_tenants_with_different_overrides(
43
+ self, authenticated_client, tenant_client, gated
44
+ ):
45
+ async with tenant_client() as a, tenant_client() as b:
46
+ await _put(authenticated_client, tenant_url(a.tenant_id), True)
47
+ await _put(authenticated_client, tenant_url(b.tenant_id), False)
48
+ assert (await a.client.get(gated)).status_code == 200
49
+ assert (await b.client.get(gated)).status_code == 404
50
+
51
+ async def test_tenant_override_beats_system_in_both_directions(
52
+ self, authenticated_client, tenant_client, gated
53
+ ):
54
+ async with tenant_client() as a, tenant_client() as b, tenant_client() as c:
55
+ await _put(authenticated_client, SYSTEM, True)
56
+ await _put(authenticated_client, tenant_url(a.tenant_id), False)
57
+ # system on: A overridden off, B inherits on
58
+ assert (await a.client.get(gated)).status_code == 404
59
+ assert (await b.client.get(gated)).status_code == 200
60
+ await _put(authenticated_client, SYSTEM, False)
61
+ await _put(authenticated_client, tenant_url(c.tenant_id), True)
62
+ # system off: C overridden on, B inherits off, A keeps its own off
63
+ assert (await c.client.get(gated)).status_code == 200
64
+ assert (await b.client.get(gated)).status_code == 404
65
+
66
+ async def test_default_applies_when_nothing_is_overridden(self, tenant_client, gated):
67
+ async with tenant_client() as a:
68
+ assert (await a.client.get(gated)).status_code == 404
69
+
70
+ async def test_clearing_a_tenant_override_falls_back_to_system(
71
+ self, authenticated_client, tenant_client, gated
72
+ ):
73
+ async with tenant_client() as a:
74
+ await _put(authenticated_client, SYSTEM, True)
75
+ await _put(authenticated_client, tenant_url(a.tenant_id), False)
76
+ assert (await a.client.get(gated)).status_code == 404
77
+ cleared = await authenticated_client.delete(tenant_url(a.tenant_id))
78
+ assert cleared.status_code == 204
79
+ assert (await a.client.get(gated)).status_code == 200
80
+
81
+
82
+ class TestBootHydration:
83
+ async def test_loads_every_tenants_overrides_with_no_tenant_bound(self, app, tenant_client):
84
+ assert app.state.sm.db.tenant_strict # strict: an unscoped tenant read would raise
85
+ async with tenant_client() as a, tenant_client() as b:
86
+ async with app.state.sm.db.session_factory() as db:
87
+ service = FeatureFlagService(db)
88
+ await service.set_override(FLAG, True, scope="tenant", scope_id=a.tenant_id)
89
+ await service.set_override(FLAG, False, scope="tenant", scope_id=b.tenant_id)
90
+ await db.commit()
91
+
92
+ fresh = FeatureFlagRegistry()
93
+ async with app.state.sm.db.session_factory() as db:
94
+ loaded = await FeatureFlagService(db).hydrate_registry(fresh)
95
+
96
+ assert loaded >= 2
97
+ assert fresh.tenant_override(FLAG, a.tenant_id) is True
98
+ assert fresh.tenant_override(FLAG, b.tenant_id) is False
99
+
100
+
101
+ class TestTenantRolesNeverManageFlags:
102
+ @pytest.mark.parametrize("role", list(TenantRole))
103
+ def test_no_tenant_role_holds_flag_permissions(self, app, role):
104
+ mapped = set(app.state.sm.permissions.role_map[tenant_role(role)])
105
+ assert PERM_FEATURE_FLAGS_MANAGE not in mapped
106
+ assert PERM_FEATURE_FLAGS_VIEW not in mapped
107
+
108
+ @pytest.mark.parametrize("role", list(TenantRole))
109
+ async def test_tenant_member_cannot_reach_the_admin_endpoints(self, tenant_client, role):
110
+ async with tenant_client(role) as m:
111
+ own = tenant_url(m.tenant_id)
112
+ assert (await m.client.put(own, json={"enabled": True})).status_code == 403
113
+ assert (await m.client.delete(own)).status_code == 403
114
+ listing = await m.client.get(f"/api/feature_flags/tenant/{m.tenant_id}")
115
+ assert listing.status_code == 403
116
+
117
+
118
+ class TestUnknownTenantIsRejected:
119
+ async def test_set_override_for_an_unknown_tenant_404s_and_persists_nothing(
120
+ self, authenticated_client, app
121
+ ):
122
+ resp = await authenticated_client.put(tenant_url("no-such-tenant"), json={"enabled": True})
123
+ assert resp.status_code == 404
124
+ assert app.state.sm.feature_flags.tenant_override(FLAG, "no-such-tenant") is None
125
+ async with app.state.sm.db.session_factory() as db:
126
+ known = await FeatureFlagService(db).list_tenants_with_overrides()
127
+ assert "no-such-tenant" not in known
128
+
129
+ async def test_listing_an_unknown_tenant_404s(self, authenticated_client):
130
+ resp = await authenticated_client.get("/api/feature_flags/tenant/no-such-tenant")
131
+ assert resp.status_code == 404
132
+
133
+ async def test_known_tenant_is_accepted(self, authenticated_client, tenant_client):
134
+ async with tenant_client() as a:
135
+ await _put(authenticated_client, tenant_url(a.tenant_id), True)
136
+ listing = await authenticated_client.get(f"/api/feature_flags/tenant/{a.tenant_id}")
137
+ assert listing.status_code == 200
138
+
139
+ async def test_browse_view_rejects_an_unknown_tenant(self, authenticated_client):
140
+ resp = await authenticated_client.get("/admin/feature-flags/?tenant_id=nope")
141
+ assert resp.status_code == 404
142
+ assert (await authenticated_client.get("/admin/feature-flags/")).status_code == 200
143
+
144
+ async def test_an_orphaned_override_can_still_be_cleared(self, authenticated_client, app):
145
+ async with app.state.sm.db.session_factory() as db:
146
+ await FeatureFlagService(db).set_override(
147
+ FLAG, True, registry=app.state.sm.feature_flags, scope="tenant", scope_id="gone"
148
+ )
149
+ await db.commit()
150
+ resp = await authenticated_client.delete(tenant_url("gone"))
151
+ assert resp.status_code == 204
152
+
153
+
154
+ async def test_without_a_tenant_directory_any_id_is_accepted(authenticated_client, app):
155
+ """No ``tenants`` module installed means nobody can say a tenant is unknown."""
156
+ saved = app.state.tenant_exists
157
+ del app.state.tenant_exists
158
+ try:
159
+ resp = await authenticated_client.put(tenant_url("anything"), json={"enabled": True})
160
+ finally:
161
+ app.state.tenant_exists = saved
162
+ assert resp.status_code == 200
@@ -1,27 +0,0 @@
1
- """FastAPI dependencies for the feature_flags module."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Annotated
6
-
7
- from fastapi import Depends, Request
8
- from simple_module_core.feature_flags import FeatureFlagRegistry
9
- from simple_module_db.deps import get_db
10
- from sqlalchemy.ext.asyncio import AsyncSession
11
-
12
- from feature_flags.service import FeatureFlagService
13
-
14
-
15
- async def get_feature_flag_service(
16
- db: AsyncSession = Depends(get_db),
17
- ) -> FeatureFlagService:
18
- return FeatureFlagService(db)
19
-
20
-
21
- def get_feature_flag_registry(request: Request) -> FeatureFlagRegistry:
22
- """Return the process-wide FeatureFlagRegistry owned by the framework."""
23
- return request.app.state.sm.feature_flags
24
-
25
-
26
- FeatureFlagServiceDep = Annotated[FeatureFlagService, Depends(get_feature_flag_service)]
27
- FeatureFlagRegistryDep = Annotated[FeatureFlagRegistry, Depends(get_feature_flag_registry)]