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.
- sahra_shared/__init__.py +0 -0
- sahra_shared/auth/__init__.py +53 -0
- sahra_shared/auth/capabilities.py +299 -0
- sahra_shared/auth/claims.py +90 -0
- sahra_shared/auth/coverage.py +248 -0
- sahra_shared/auth/dependencies.py +153 -0
- sahra_shared/auth/guards.py +211 -0
- sahra_shared/events/__init__.py +5 -0
- sahra_shared/events/connection.py +66 -0
- sahra_shared/events/consumer.py +140 -0
- sahra_shared/events/envelope.py +18 -0
- sahra_shared/events/publisher.py +76 -0
- sahra_shared/events/subjects.py +43 -0
- sahra_shared/http/__init__.py +5 -0
- sahra_shared/http/exceptions.py +101 -0
- sahra_shared/http/health.py +28 -0
- sahra_shared/http/models.py +37 -0
- sahra_shared/privacy/__init__.py +13 -0
- sahra_shared/privacy/policies.py +53 -0
- sahra_shared/privacy/types.py +12 -0
- sahra_shared/telemetry/__init__.py +64 -0
- sahra_shared/telemetry/context.py +46 -0
- sahra_shared/telemetry/http_client.py +50 -0
- sahra_shared/telemetry/http_middleware.py +108 -0
- sahra_shared/telemetry/lifecycle.py +193 -0
- sahra_shared/telemetry/logging.py +123 -0
- sahra_shared/telemetry/metrics.py +54 -0
- sahra_shared/telemetry/otel.py +37 -0
- sahra_shared/tenancy/__init__.py +89 -0
- sahra_shared/tenancy/context.py +137 -0
- sahra_shared/tenancy/rls.py +250 -0
- sahra_shared/tenancy/routing.py +108 -0
- sahra_shared/tenancy/session.py +244 -0
- sahra_shared/tenancy/verify.py +351 -0
- sahra_shared-0.2.2.dist-info/METADATA +106 -0
- sahra_shared-0.2.2.dist-info/RECORD +38 -0
- sahra_shared-0.2.2.dist-info/WHEEL +4 -0
- sahra_shared-0.2.2.dist-info/licenses/LICENSE +21 -0
sahra_shared/__init__.py
ADDED
|
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)
|