sahra-shared 0.2.2__py3-none-any.whl

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 (38) hide show
  1. sahra_shared/__init__.py +0 -0
  2. sahra_shared/auth/__init__.py +53 -0
  3. sahra_shared/auth/capabilities.py +299 -0
  4. sahra_shared/auth/claims.py +90 -0
  5. sahra_shared/auth/coverage.py +248 -0
  6. sahra_shared/auth/dependencies.py +153 -0
  7. sahra_shared/auth/guards.py +211 -0
  8. sahra_shared/events/__init__.py +5 -0
  9. sahra_shared/events/connection.py +66 -0
  10. sahra_shared/events/consumer.py +140 -0
  11. sahra_shared/events/envelope.py +18 -0
  12. sahra_shared/events/publisher.py +76 -0
  13. sahra_shared/events/subjects.py +43 -0
  14. sahra_shared/http/__init__.py +5 -0
  15. sahra_shared/http/exceptions.py +101 -0
  16. sahra_shared/http/health.py +28 -0
  17. sahra_shared/http/models.py +37 -0
  18. sahra_shared/privacy/__init__.py +13 -0
  19. sahra_shared/privacy/policies.py +53 -0
  20. sahra_shared/privacy/types.py +12 -0
  21. sahra_shared/telemetry/__init__.py +64 -0
  22. sahra_shared/telemetry/context.py +46 -0
  23. sahra_shared/telemetry/http_client.py +50 -0
  24. sahra_shared/telemetry/http_middleware.py +108 -0
  25. sahra_shared/telemetry/lifecycle.py +193 -0
  26. sahra_shared/telemetry/logging.py +123 -0
  27. sahra_shared/telemetry/metrics.py +54 -0
  28. sahra_shared/telemetry/otel.py +37 -0
  29. sahra_shared/tenancy/__init__.py +89 -0
  30. sahra_shared/tenancy/context.py +137 -0
  31. sahra_shared/tenancy/rls.py +250 -0
  32. sahra_shared/tenancy/routing.py +108 -0
  33. sahra_shared/tenancy/session.py +244 -0
  34. sahra_shared/tenancy/verify.py +351 -0
  35. sahra_shared-0.2.2.dist-info/METADATA +106 -0
  36. sahra_shared-0.2.2.dist-info/RECORD +38 -0
  37. sahra_shared-0.2.2.dist-info/WHEEL +4 -0
  38. sahra_shared-0.2.2.dist-info/licenses/LICENSE +21 -0
File without changes
@@ -0,0 +1,53 @@
1
+ from sahra_shared.auth.capabilities import (
2
+ FOREIGN_VOCABULARY,
3
+ ROLE_CAPABILITIES,
4
+ Capability,
5
+ RoleName,
6
+ capabilities_for_roles,
7
+ )
8
+ from sahra_shared.auth.claims import TokenClaims
9
+ from sahra_shared.auth.coverage import (
10
+ RouteDeclaration,
11
+ UndeclaredRouteError,
12
+ assert_capability_coverage,
13
+ describe_routes,
14
+ enforce_capability_coverage,
15
+ )
16
+ from sahra_shared.auth.dependencies import (
17
+ ForeignVocabularyError,
18
+ make_auth_dependency,
19
+ require_role,
20
+ )
21
+ from sahra_shared.auth.guards import (
22
+ CapabilityGuards,
23
+ CapabilityRequirement,
24
+ declared_capabilities,
25
+ internal_endpoint,
26
+ internal_reason,
27
+ public_endpoint,
28
+ public_reason,
29
+ )
30
+
31
+ __all__ = [
32
+ "FOREIGN_VOCABULARY",
33
+ "ROLE_CAPABILITIES",
34
+ "Capability",
35
+ "CapabilityGuards",
36
+ "CapabilityRequirement",
37
+ "ForeignVocabularyError",
38
+ "RoleName",
39
+ "RouteDeclaration",
40
+ "TokenClaims",
41
+ "UndeclaredRouteError",
42
+ "assert_capability_coverage",
43
+ "capabilities_for_roles",
44
+ "declared_capabilities",
45
+ "describe_routes",
46
+ "enforce_capability_coverage",
47
+ "internal_endpoint",
48
+ "internal_reason",
49
+ "make_auth_dependency",
50
+ "public_endpoint",
51
+ "public_reason",
52
+ "require_role",
53
+ ]
@@ -0,0 +1,299 @@
1
+ """Closed capability vocabulary for Sahra Trans authorization.
2
+
3
+ Why capabilities and not roles
4
+ ------------------------------
5
+ Authorization used to be expressed by string-matching whatever the caller
6
+ believed the token's ``roles`` claim contained. That produced a platform-wide
7
+ defect: guards were written as ``require_role(url, "CARRIER_MANAGER",
8
+ "PLATFORM_ADMIN")`` — those are ``UserType`` values, while the JWT's ``roles``
9
+ claim is populated from ``user_roles.role_type`` (``RoleType`` values). The two
10
+ vocabularies never intersect, so every such guard evaluated to ``False`` and
11
+ returned 403 to everybody, forever, silently.
12
+
13
+ The fix is not "use the right strings". The fix is to stop letting a guard name
14
+ a value from another bounded context at all. Routes declare **capabilities**;
15
+ capabilities are granted to **role names**; the role names are the only place
16
+ the identity domain's vocabulary is referenced, and that place is a single
17
+ audited table in this module.
18
+
19
+ Why ``StrEnum`` and not ``Literal`` or a frozen set
20
+ --------------------------------------------------
21
+ The brief's requirement is that an unknown capability is a **Pyright error**,
22
+ not a runtime 403. All three candidates satisfy that requirement, so the tie is
23
+ broken on the other requirements:
24
+
25
+ * ``Literal["fleet:vehicle.read", ...]`` — strongest string checking, but the
26
+ closed set exists only in the type system. The route-coverage test, the
27
+ ``/authz/capabilities`` introspection endpoint and the OpenAPI description
28
+ generator all need to *enumerate* the vocabulary at runtime, which a
29
+ ``Literal`` alias cannot do without a hand-maintained duplicate list. Two
30
+ lists that must agree is exactly the failure mode we are removing.
31
+ * ``frozenset[str]`` — enumerable, but a typo is a runtime membership miss, not
32
+ a type error. That is the defect we are fixing, reintroduced.
33
+ * ``StrEnum`` — enumerable at runtime (``list(Capability)``), and a typo is
34
+ caught **twice**: ``Capability.FLEET_VEHICLE_RAED`` is a Pyright
35
+ ``reportAttributeAccessIssue`` *and* an ``AttributeError`` at import time,
36
+ so a mistyped guard crashes the service at startup rather than 403-ing at
37
+ request time. Passing a foreign vocabulary value —
38
+ ``requires("CARRIER_MANAGER")`` or ``requires(RoleType.SUPER_ADMIN)`` — is a
39
+ Pyright ``reportArgumentType`` error, because ``str`` and a *different* enum
40
+ are both unassignable to ``Capability``.
41
+
42
+ ``StrEnum`` therefore wins: it is the only option that makes the vocabulary
43
+ both machine-enumerable and typo-fatal-at-import.
44
+
45
+ Extending the vocabulary
46
+ ------------------------
47
+ This set is deliberately closed. Adding a member is a reviewed change to this
48
+ file: it is the platform's authorization contract, not a per-service detail.
49
+ Do not invent capability strings at a call site — there is nowhere to put one.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ from enum import StrEnum
55
+ from typing import Final
56
+
57
+
58
+ class Capability(StrEnum):
59
+ """The complete, closed set of things a principal may be permitted to do.
60
+
61
+ Wire format is ``domain:object.action``. The value is what appears in logs
62
+ and in OpenAPI descriptions; the member name is what code references.
63
+
64
+ Two capabilities are special and are called out explicitly rather than
65
+ being inferred from a role name:
66
+
67
+ * :attr:`PLATFORM_TENANT_BYPASS` — the *only* thing that lets a request read
68
+ or write outside its own organization. Tenant scoping keys off this and
69
+ nothing else.
70
+ * :attr:`PLATFORM_ADMINISTER` — platform back-office access. Held alongside
71
+ the bypass by platform staff, but separate so that a future
72
+ read-only-auditor role can bypass tenancy without gaining write access.
73
+ """
74
+
75
+ # ── Platform / cross-tenant ──────────────────────────────────────────────
76
+ PLATFORM_TENANT_BYPASS = "platform:tenant.bypass"
77
+ PLATFORM_ADMINISTER = "platform:administer"
78
+ PLATFORM_ORG_APPROVE = "platform:org.approve"
79
+ PLATFORM_ORG_SUSPEND = "platform:org.suspend"
80
+ PLATFORM_REFERENCE_DATA_WRITE = "platform:reference_data.write"
81
+ PLATFORM_CARRIER_APPLICATION_REVIEW = "platform:carrier_application.review"
82
+
83
+ # ── Own organization ─────────────────────────────────────────────────────
84
+ ORG_READ = "org:profile.read"
85
+ ORG_UPDATE = "org:profile.update"
86
+ ORG_BRANCH_READ = "org:branch.read"
87
+ ORG_BRANCH_WRITE = "org:branch.write"
88
+
89
+ # ── Users within the organization ────────────────────────────────────────
90
+ ORG_USER_READ = "org:user.read"
91
+ ORG_USER_INVITE = "org:user.invite"
92
+ ORG_USER_UPDATE = "org:user.update"
93
+ ORG_USER_SUSPEND = "org:user.suspend"
94
+ ORG_ROLE_ASSIGN = "org:role.assign"
95
+
96
+ # ── Fleet (master data) ──────────────────────────────────────────────────
97
+ FLEET_VEHICLE_READ = "fleet:vehicle.read"
98
+ FLEET_VEHICLE_WRITE = "fleet:vehicle.write"
99
+ FLEET_DRIVER_READ = "fleet:driver.read"
100
+ FLEET_DRIVER_WRITE = "fleet:driver.write"
101
+
102
+ # ── Compliance & commercial (master data) ────────────────────────────────
103
+ COMPLIANCE_PROFILE_READ = "compliance:profile.read"
104
+ COMPLIANCE_PROFILE_WRITE = "compliance:profile.write"
105
+ SUBSCRIPTION_READ = "subscription:plan.read"
106
+ SUBSCRIPTION_MANAGE = "subscription:plan.manage"
107
+ CONSIGNEE_READ = "consignee:record.read"
108
+ CONSIGNEE_WRITE = "consignee:record.write"
109
+ WAREHOUSE_READ = "warehouse:site.read"
110
+ WAREHOUSE_WRITE = "warehouse:site.write"
111
+
112
+ # ── Marketplace & operations ─────────────────────────────────────────────
113
+ BOOKING_READ = "booking:record.read"
114
+ BOOKING_CREATE = "booking:record.create"
115
+ BOOKING_CANCEL = "booking:record.cancel"
116
+ SHIPMENT_READ = "shipment:record.read"
117
+ SHIPMENT_STATUS_UPDATE = "shipment:record.status_update"
118
+
119
+ # ── Finance ──────────────────────────────────────────────────────────────
120
+ FINANCE_INVOICE_READ = "finance:invoice.read"
121
+ FINANCE_PAYMENT_MANAGE = "finance:payment.manage"
122
+
123
+ # ── Self-service (every authenticated principal) ─────────────────────────
124
+ SELF_PROFILE_READ = "self:profile.read"
125
+ SELF_PROFILE_UPDATE = "self:profile.update"
126
+
127
+
128
+ class RoleName(StrEnum):
129
+ """The identity domain's ``RoleType`` values, mirrored deliberately.
130
+
131
+ This is the **single** place in the shared kernel that names a value from
132
+ the identity bounded context. It mirrors
133
+ ``identity-svc/app/schemas/enums.py::RoleType`` exactly, because that enum's
134
+ ``.value`` is what ``identity-svc`` puts in the JWT ``roles`` claim
135
+ (``app/services/auth_service.py``: ``roles = [r.role_type.value for r in
136
+ roles_objs]``).
137
+
138
+ It is mirrored rather than imported because the shared kernel must not
139
+ depend on any service (rule: no cross-service imports). The mirror is
140
+ guarded by a test that fails if the two drift.
141
+
142
+ Note the trap this replaces: ``UserType`` (``SHIPPER_USER``,
143
+ ``CARRIER_MANAGER``, ``CARRIER_DRIVER``, ``CONSIGNEE``, ``PLATFORM_ADMIN``)
144
+ is a *different* enum and never reaches the ``roles`` claim. No member of it
145
+ appears below, and none ever should.
146
+ """
147
+
148
+ ORG_OWNER = "ORG_OWNER"
149
+ OPERATIONS_MANAGER = "OPERATIONS_MANAGER"
150
+ BOOKING_AGENT = "BOOKING_AGENT"
151
+ FLEET_MANAGER = "FLEET_MANAGER"
152
+ DISPATCHER = "DISPATCHER"
153
+ FINANCE_USER = "FINANCE_USER"
154
+ VIEWER = "VIEWER"
155
+ DRIVER = "DRIVER"
156
+ BUSINESS_ADMIN = "BUSINESS_ADMIN"
157
+ SUPER_ADMIN = "SUPER_ADMIN"
158
+
159
+
160
+ # Values that are NOT role names and must never be accepted as one. These are
161
+ # `UserType` members plus the lowercase legacy spellings that were used in the
162
+ # broken helpers. `require_role` rejects them loudly instead of returning a
163
+ # permanent 403 (see `sahra_shared.auth.dependencies`).
164
+ FOREIGN_VOCABULARY: Final[frozenset[str]] = frozenset(
165
+ {
166
+ # identity-svc UserType — the source of the always-False guard defect.
167
+ "SHIPPER_USER",
168
+ "CARRIER_MANAGER",
169
+ "CARRIER_DRIVER",
170
+ "CONSIGNEE",
171
+ "PLATFORM_ADMIN",
172
+ # Lowercase legacy spellings that no mint path ever produces.
173
+ "platform_admin",
174
+ "shipper_user",
175
+ "carrier_manager",
176
+ "carrier_driver",
177
+ "consignee",
178
+ }
179
+ )
180
+
181
+
182
+ _SELF_SERVICE: Final[frozenset[Capability]] = frozenset(
183
+ {
184
+ Capability.SELF_PROFILE_READ,
185
+ Capability.SELF_PROFILE_UPDATE,
186
+ Capability.ORG_READ,
187
+ }
188
+ )
189
+
190
+ _VIEWER: Final[frozenset[Capability]] = _SELF_SERVICE | {
191
+ Capability.ORG_BRANCH_READ,
192
+ Capability.ORG_USER_READ,
193
+ Capability.FLEET_VEHICLE_READ,
194
+ Capability.FLEET_DRIVER_READ,
195
+ Capability.COMPLIANCE_PROFILE_READ,
196
+ Capability.SUBSCRIPTION_READ,
197
+ Capability.CONSIGNEE_READ,
198
+ Capability.WAREHOUSE_READ,
199
+ Capability.BOOKING_READ,
200
+ Capability.SHIPMENT_READ,
201
+ }
202
+
203
+ _OPERATIONS: Final[frozenset[Capability]] = _VIEWER | {
204
+ Capability.CONSIGNEE_WRITE,
205
+ Capability.WAREHOUSE_WRITE,
206
+ Capability.BOOKING_CREATE,
207
+ Capability.BOOKING_CANCEL,
208
+ Capability.SHIPMENT_STATUS_UPDATE,
209
+ }
210
+
211
+ _FLEET: Final[frozenset[Capability]] = _VIEWER | {
212
+ Capability.FLEET_VEHICLE_WRITE,
213
+ Capability.FLEET_DRIVER_WRITE,
214
+ Capability.COMPLIANCE_PROFILE_WRITE,
215
+ }
216
+
217
+ _ORG_OWNER: Final[frozenset[Capability]] = (
218
+ _OPERATIONS
219
+ | _FLEET
220
+ | {
221
+ Capability.ORG_UPDATE,
222
+ Capability.ORG_BRANCH_WRITE,
223
+ Capability.ORG_USER_INVITE,
224
+ Capability.ORG_USER_UPDATE,
225
+ Capability.ORG_USER_SUSPEND,
226
+ Capability.ORG_ROLE_ASSIGN,
227
+ Capability.SUBSCRIPTION_MANAGE,
228
+ Capability.FINANCE_INVOICE_READ,
229
+ Capability.FINANCE_PAYMENT_MANAGE,
230
+ }
231
+ )
232
+
233
+ # Platform staff. `PLATFORM_TENANT_BYPASS` is granted here and NOWHERE else —
234
+ # it is the single switch that permits cross-organization access, so it is
235
+ # trivial to audit by grepping this file.
236
+ _SUPER_ADMIN: Final[frozenset[Capability]] = frozenset(Capability)
237
+
238
+ _BUSINESS_ADMIN: Final[frozenset[Capability]] = _ORG_OWNER | {
239
+ Capability.PLATFORM_ADMINISTER,
240
+ Capability.PLATFORM_ORG_APPROVE,
241
+ Capability.PLATFORM_ORG_SUSPEND,
242
+ Capability.PLATFORM_REFERENCE_DATA_WRITE,
243
+ Capability.PLATFORM_CARRIER_APPLICATION_REVIEW,
244
+ Capability.PLATFORM_TENANT_BYPASS,
245
+ }
246
+
247
+ ROLE_CAPABILITIES: Final[dict[RoleName, frozenset[Capability]]] = {
248
+ RoleName.SUPER_ADMIN: _SUPER_ADMIN,
249
+ RoleName.BUSINESS_ADMIN: _BUSINESS_ADMIN,
250
+ RoleName.ORG_OWNER: _ORG_OWNER,
251
+ RoleName.OPERATIONS_MANAGER: _OPERATIONS,
252
+ RoleName.FLEET_MANAGER: _FLEET,
253
+ RoleName.BOOKING_AGENT: _VIEWER
254
+ | {
255
+ Capability.BOOKING_CREATE,
256
+ Capability.BOOKING_CANCEL,
257
+ Capability.CONSIGNEE_WRITE,
258
+ },
259
+ RoleName.DISPATCHER: _VIEWER | {Capability.SHIPMENT_STATUS_UPDATE},
260
+ RoleName.FINANCE_USER: _VIEWER
261
+ | {
262
+ Capability.FINANCE_INVOICE_READ,
263
+ Capability.FINANCE_PAYMENT_MANAGE,
264
+ Capability.SUBSCRIPTION_READ,
265
+ },
266
+ RoleName.VIEWER: _VIEWER,
267
+ RoleName.DRIVER: _SELF_SERVICE
268
+ | {
269
+ Capability.SHIPMENT_READ,
270
+ Capability.SHIPMENT_STATUS_UPDATE,
271
+ },
272
+ }
273
+
274
+
275
+ def capabilities_for_roles(roles: object) -> frozenset[Capability]:
276
+ """Resolve a JWT ``roles`` claim to the capabilities it grants.
277
+
278
+ Unknown role strings are **ignored, not rejected**: a token minted by a
279
+ newer ``identity-svc`` that has grown an eleventh role must not 500 an older
280
+ service. Ignoring is safe because it can only ever *reduce* the granted set
281
+ — the failure mode is a 403, which is the correct direction to fail.
282
+
283
+ ``roles`` is typed ``object`` because it arrives from a decoded JWT and is
284
+ only as trustworthy as the signature; anything that is not a list of
285
+ strings resolves to no capabilities at all.
286
+ """
287
+ if not isinstance(roles, list):
288
+ return frozenset()
289
+
290
+ granted: set[Capability] = set()
291
+ for raw in roles: # pyright: ignore[reportUnknownVariableType]
292
+ if not isinstance(raw, str):
293
+ continue
294
+ try:
295
+ role = RoleName(raw)
296
+ except ValueError:
297
+ continue
298
+ granted |= ROLE_CAPABILITIES.get(role, frozenset())
299
+ return frozenset(granted)
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+ from typing import Final
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from sahra_shared.auth.capabilities import Capability, capabilities_for_roles
9
+
10
+ # Legacy spelling kept ONLY so the pre-existing contract test in
11
+ # `tests/test_auth.py` (which asserts `roles=["driver", "platform_admin"]` is a
12
+ # platform admin) keeps passing unchanged. It is unreachable from any real mint
13
+ # path: `identity-svc/app/services/auth_service.py` populates `roles` from
14
+ # `RoleType.value`, which is always UPPERCASE, and `platform_admin` is a
15
+ # lowercased `UserType` value that no signer ever emits. Scheduled for removal
16
+ # once that test is rewritten against `RoleName.SUPER_ADMIN` by the repo owner.
17
+ _LEGACY_PLATFORM_ADMIN_ALIASES: Final[frozenset[str]] = frozenset({"platform_admin"})
18
+
19
+ # `org_type` reaches the token lowercased — `identity-svc` does
20
+ # `org.org_type.value.lower()` at `app/services/auth_service.py` and
21
+ # `app/api/internal.py`. The comparisons below are casefolded anyway so that a
22
+ # future signer emitting the raw `OrgType` value ("CARRIER") does not silently
23
+ # turn every carrier check into False.
24
+ _CARRIER_ORG_TYPES: Final[frozenset[str]] = frozenset({"carrier", "dual"})
25
+ _SHIPPER_ORG_TYPES: Final[frozenset[str]] = frozenset({"shipper", "dual"})
26
+
27
+
28
+ class TokenClaims(BaseModel):
29
+ sub: str
30
+ org_id: str
31
+ org_type: str
32
+ roles: list[str]
33
+ branch_id: str | None
34
+ session_id: str
35
+ jti: str
36
+ exp: int
37
+ iat: int
38
+
39
+ @cached_property
40
+ def capabilities(self) -> frozenset[Capability]:
41
+ """Capabilities granted by this token's roles.
42
+
43
+ This is the only sanctioned way to ask what a principal may do. It is
44
+ derived, never carried on the wire, so widening a role's grants does not
45
+ require reissuing outstanding tokens.
46
+ """
47
+ return capabilities_for_roles(self.roles)
48
+
49
+ def has_capability(self, capability: Capability) -> bool:
50
+ """True if this principal holds ``capability``.
51
+
52
+ The parameter is typed ``Capability``, so ``has_capability("org:read")``
53
+ and ``has_capability(RoleType.SUPER_ADMIN)`` are both Pyright errors.
54
+ """
55
+ return capability in self.capabilities
56
+
57
+ def has_role(self, role: str) -> bool:
58
+ """Raw membership test against the ``roles`` claim.
59
+
60
+ Prefer :meth:`has_capability`. This remains only for callers that must
61
+ inspect the identity domain's vocabulary directly.
62
+ """
63
+ return role in self.roles
64
+
65
+ def is_platform_admin(self) -> bool:
66
+ """True for platform staff, i.e. principals permitted to leave their tenant.
67
+
68
+ Previously this was ``has_role("platform_admin")``, which was always
69
+ False: ``platform_admin`` is a lowercased ``UserType`` value, while the
70
+ ``roles`` claim carries ``RoleType`` values (the seeded platform admin
71
+ carries ``["SUPER_ADMIN"]``). Every caller of this helper —
72
+ ``master-data-svc``'s ``_assert_org_scope`` and all six guards in
73
+ ``operator-svc/app/api/carrier_applications.py`` — was therefore dead.
74
+ """
75
+ if Capability.PLATFORM_TENANT_BYPASS in self.capabilities:
76
+ return True
77
+ return any(alias in self.roles for alias in _LEGACY_PLATFORM_ADMIN_ALIASES)
78
+
79
+ def is_carrier(self) -> bool:
80
+ """True if the principal's organization operates as a carrier.
81
+
82
+ Casefolded, and ``DUAL`` organizations count as both carrier and
83
+ shipper — ``OrgType`` has four members (``SHIPPER``, ``CARRIER``,
84
+ ``DUAL``, ``PLATFORM``) and a ``DUAL`` org previously matched neither.
85
+ """
86
+ return self.org_type.casefold() in _CARRIER_ORG_TYPES
87
+
88
+ def is_shipper(self) -> bool:
89
+ """True if the principal's organization operates as a shipper (see :meth:`is_carrier`)."""
90
+ return self.org_type.casefold() in _SHIPPER_ORG_TYPES
@@ -0,0 +1,248 @@
1
+ """Route-coverage enforcement: fail on any route that declares no authorization.
2
+
3
+ The premise is that authorization you can forget is authorization you will
4
+ forget. FastAPI's default for a route with no security dependency is *open*, so
5
+ the omission is invisible: nothing 500s, nothing logs, the endpoint simply
6
+ serves everyone. This module inverts that default by making the omission a
7
+ **test failure** (and optionally a **startup failure**).
8
+
9
+ Usage in a service's test suite — one line::
10
+
11
+ from sahra_shared.auth import assert_capability_coverage
12
+ from app.main import app
13
+
14
+ def test_every_route_declares_authorization() -> None:
15
+ assert_capability_coverage(app)
16
+
17
+ Walking the routes
18
+ ------------------
19
+ FastAPI 0.140+ no longer flattens ``include_router()`` into ``app.routes``: an
20
+ included router appears as a single lazy ``_IncludedRouter`` object and its
21
+ routes are materialised on demand. Iterating ``app.routes`` and filtering for
22
+ ``APIRoute`` therefore finds **nothing** in any real service, which would make
23
+ this helper pass vacuously — the worst possible failure for a test whose entire
24
+ job is to catch omissions.
25
+
26
+ We use ``fastapi.routing.iter_route_contexts``, the same public helper FastAPI's
27
+ own OpenAPI generator uses, and fall back to a flat walk on older versions. A
28
+ test asserts that a router-included route really is discovered, so a future
29
+ FastAPI change cannot quietly re-open the hole.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Iterable, Iterator, Sequence
35
+ from dataclasses import dataclass
36
+ from typing import Any
37
+
38
+ from fastapi import FastAPI
39
+ from fastapi.routing import APIRoute, APIWebSocketRoute
40
+
41
+ from sahra_shared.auth.capabilities import Capability
42
+ from sahra_shared.auth.guards import declared_capabilities, internal_reason, public_reason
43
+
44
+ try: # FastAPI >= 0.140
45
+ from fastapi.routing import iter_route_contexts as _iter_route_contexts
46
+ except ImportError: # pragma: no cover # older FastAPI keeps a flat route list
47
+ _iter_route_contexts = None
48
+
49
+ #: Paths FastAPI itself mounts. They are documentation surfaces, not application
50
+ #: routes, and a service cannot attach dependencies to them without replacing
51
+ #: them wholesale. Everything else that is not an ``APIRoute`` is reported.
52
+ FRAMEWORK_PATHS: frozenset[str] = frozenset(
53
+ {
54
+ "/openapi.json",
55
+ "/docs",
56
+ "/docs/oauth2-redirect",
57
+ "/redoc",
58
+ }
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class RouteDeclaration:
64
+ """What one route declares about its own authorization."""
65
+
66
+ methods: tuple[str, ...]
67
+ path: str
68
+ name: str
69
+ capabilities: frozenset[Capability]
70
+ public_reason: str | None
71
+ kind: str = "APIRoute"
72
+ internal_reason: str | None = None
73
+
74
+ @property
75
+ def is_declared(self) -> bool:
76
+ return bool(self.capabilities) or self.public_reason is not None or self.internal_reason is not None
77
+
78
+ def __str__(self) -> str:
79
+ methods = ",".join(self.methods) or "WS"
80
+ return f"{methods} {self.path} (endpoint={self.name}, {self.kind})"
81
+
82
+
83
+ class UndeclaredRouteError(AssertionError):
84
+ """Raised when at least one route declares no authorization.
85
+
86
+ Subclasses ``AssertionError`` so it reads naturally as a test failure while
87
+ still being catchable by type at startup.
88
+ """
89
+
90
+ def __init__(self, undeclared: Sequence[RouteDeclaration]) -> None:
91
+ self.undeclared: tuple[RouteDeclaration, ...] = tuple(undeclared)
92
+ listing = "\n".join(f" - {route}" for route in undeclared)
93
+ super().__init__(
94
+ f"{len(undeclared)} route(s) declare no authorization.\n"
95
+ f"{listing}\n\n"
96
+ "Every route must declare one of: Depends(guards.requires(Capability...)), "
97
+ "Depends(public_endpoint('reason')), or Depends(internal_endpoint('reason')). "
98
+ "A route with none of them is not public — it is undeclared, and "
99
+ "deny-by-default treats that as a failure."
100
+ )
101
+
102
+
103
+ def _walk_dependants(dependant: Any) -> Iterator[Any]:
104
+ """Yield every dependency callable reachable from ``dependant``, depth-first.
105
+
106
+ Walking the resolved dependant tree (rather than the endpoint signature) is
107
+ what makes a declaration discoverable no matter where it was attached: the
108
+ endpoint signature, ``APIRouter(dependencies=[...])``,
109
+ ``include_router(..., dependencies=[...])`` and
110
+ ``FastAPI(dependencies=[...])`` all converge on the same tree.
111
+ """
112
+ if dependant is None:
113
+ return
114
+ call = getattr(dependant, "call", None)
115
+ if call is not None:
116
+ yield call
117
+ for sub in getattr(dependant, "dependencies", None) or ():
118
+ yield from _walk_dependants(sub)
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class _RawRoute:
123
+ route: Any
124
+ path: str
125
+ methods: tuple[str, ...]
126
+ name: str
127
+ dependant: Any
128
+
129
+
130
+ def _iter_raw_routes(app: FastAPI) -> Iterator[_RawRoute]:
131
+ if _iter_route_contexts is not None:
132
+ for ctx in _iter_route_contexts(app.routes):
133
+ yield _RawRoute(
134
+ route=ctx.original_route,
135
+ path=ctx.path or "",
136
+ methods=tuple(sorted(ctx.methods or ())),
137
+ name=ctx.name or "",
138
+ dependant=getattr(ctx, "dependant", None),
139
+ )
140
+ return
141
+ for route in app.routes: # pragma: no cover # legacy FastAPI path
142
+ yield _RawRoute(
143
+ route=route,
144
+ path=str(getattr(route, "path", "")),
145
+ methods=tuple(sorted(getattr(route, "methods", None) or ())),
146
+ name=str(getattr(route, "name", "")),
147
+ dependant=getattr(route, "dependant", None),
148
+ )
149
+
150
+
151
+ def _describe(raw: _RawRoute) -> RouteDeclaration:
152
+ capabilities: set[Capability] = set()
153
+ reason: str | None = None
154
+ internal: str | None = None
155
+ for call in _walk_dependants(raw.dependant):
156
+ declared = declared_capabilities(call)
157
+ if declared:
158
+ capabilities |= declared
159
+ if reason is None:
160
+ reason = public_reason(call)
161
+ if internal is None:
162
+ internal = internal_reason(call)
163
+
164
+ return RouteDeclaration(
165
+ methods=raw.methods,
166
+ path=raw.path,
167
+ name=raw.name,
168
+ capabilities=frozenset(capabilities),
169
+ public_reason=reason,
170
+ kind=type(raw.route).__name__,
171
+ internal_reason=internal,
172
+ )
173
+
174
+
175
+ def describe_routes(app: FastAPI) -> list[RouteDeclaration]:
176
+ """Return the authorization declaration of every application route in ``app``.
177
+
178
+ FastAPI's own documentation routes are excluded; anything else that is not
179
+ an ``APIRoute``/``APIWebSocketRoute`` — a raw Starlette ``Route``, a
180
+ ``Mount``, a sub-application — is included and will read as undeclared,
181
+ because the kernel cannot see inside it and therefore cannot vouch for it.
182
+ """
183
+ declarations: list[RouteDeclaration] = []
184
+ for raw in _iter_raw_routes(app):
185
+ if not isinstance(raw.route, APIRoute | APIWebSocketRoute) and raw.path in FRAMEWORK_PATHS:
186
+ continue
187
+ declarations.append(_describe(raw))
188
+ return declarations
189
+
190
+
191
+ def assert_capability_coverage(
192
+ app: FastAPI,
193
+ *,
194
+ allow_undeclared: Iterable[str] = (),
195
+ ) -> list[RouteDeclaration]:
196
+ """Fail unless every route in ``app`` declares its authorization.
197
+
198
+ ``allow_undeclared`` is an escape hatch of last resort, spelled as
199
+ ``"METHOD /path"`` (e.g. ``"GET /health"``). It is deliberately awkward:
200
+
201
+ * every entry must match a real undeclared route, so the list cannot rot
202
+ into a permanent, meaningless allowlist;
203
+ * it appears in the service's own test file, in the diff, in review.
204
+
205
+ Prefer ``Depends(public_endpoint(reason))`` on the route itself.
206
+
207
+ Returns every declaration, so callers can make further assertions — for
208
+ example that no route outside ``/internal`` demands
209
+ ``Capability.PLATFORM_TENANT_BYPASS``.
210
+ """
211
+ declarations = describe_routes(app)
212
+ if not declarations:
213
+ raise AssertionError(
214
+ "No routes were discovered on this app, so nothing was verified. "
215
+ "A coverage assertion that inspects zero routes is worse than none — "
216
+ "check that routers are included before this is called."
217
+ )
218
+
219
+ allowed = set(allow_undeclared)
220
+ undeclared: list[RouteDeclaration] = []
221
+ matched: set[str] = set()
222
+ for declaration in declarations:
223
+ if declaration.is_declared:
224
+ continue
225
+ keys = {f"{method} {declaration.path}" for method in declaration.methods} or {f"WS {declaration.path}"}
226
+ hit = keys & allowed
227
+ if hit:
228
+ matched |= hit
229
+ continue
230
+ undeclared.append(declaration)
231
+
232
+ stale = allowed - matched
233
+ if stale:
234
+ raise AssertionError(f"allow_undeclared contains entries that match no undeclared route: {sorted(stale)}. Remove them — a stale allowlist hides the next real gap.")
235
+
236
+ if undeclared:
237
+ raise UndeclaredRouteError(undeclared)
238
+ return declarations
239
+
240
+
241
+ def enforce_capability_coverage(app: FastAPI, *, allow_undeclared: Iterable[str] = ()) -> None:
242
+ """Startup-time equivalent of :func:`assert_capability_coverage`.
243
+
244
+ Call it at the end of app construction. It raises before the service can
245
+ accept traffic, which is the correct place to fail: an undeclared route is a
246
+ deployment defect, not a per-request authorization decision.
247
+ """
248
+ assert_capability_coverage(app, allow_undeclared=allow_undeclared)