open-shield-python 0.2.0__tar.gz → 0.2.1__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 (65) hide show
  1. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/PKG-INFO +1 -1
  2. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/pyproject.toml +1 -1
  3. open_shield_python-0.2.1/src/open_shield/domain/ports/__init__.py +5 -0
  4. open_shield_python-0.2.1/src/open_shield/domain/ports/tenant_resolver.py +37 -0
  5. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/domain/services/token_service.py +47 -10
  6. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/tests/unit/domain/test_claim_mapping.py +94 -1
  7. open_shield_python-0.2.0/src/open_shield/domain/ports/__init__.py +0 -4
  8. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/python-packaging/SKILL.md +0 -0
  9. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/python-packaging/resources/implementation-playbook.md +0 -0
  10. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/python-pro/SKILL.md +0 -0
  11. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/python-testing-patterns/SKILL.md +0 -0
  12. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/python-testing-patterns/resources/implementation-playbook.md +0 -0
  13. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/SKILL.md +0 -0
  14. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/examples/before_after_refactor.py +0 -0
  15. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/examples/clean_architecture_layout.md +0 -0
  16. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/examples/dip_ports_adapters.py +0 -0
  17. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/resources/code_review_checklist.md +0 -0
  18. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.github/workflows/ci.yml +0 -0
  19. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/.gitignore +0 -0
  20. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/CHANGELOG.md +0 -0
  21. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/CONTRIBUTING.md +0 -0
  22. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/LICENSE +0 -0
  23. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/None +0 -0
  24. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/README.md +0 -0
  25. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/architecture/system-overview.md +0 -0
  26. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/design/overview.md +0 -0
  27. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/implementation/guidelines.md +0 -0
  28. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/README.md +0 -0
  29. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/backlog.md +0 -0
  30. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/decisions/0000-template.md +0 -0
  31. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/decisions/001-clean-architecture.md +0 -0
  32. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/decisions/002-modern-tooling.md +0 -0
  33. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/decisions/003-pydantic-usage.md +0 -0
  34. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-0-init.md +0 -0
  35. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-1-core.md +0 -0
  36. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-2-authz.md +0 -0
  37. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-3-integration.md +0 -0
  38. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-4-publish.md +0 -0
  39. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/roadmap.md +0 -0
  40. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/specs.md +0 -0
  41. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/tasks.md +0 -0
  42. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/docs/planning/tech-debt.md +0 -0
  43. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/main.py +0 -0
  44. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/adapters/__init__.py +0 -0
  45. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/adapters/config.py +0 -0
  46. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/adapters/key_provider.py +0 -0
  47. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/adapters/token_validator.py +0 -0
  48. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/api/__init__.py +0 -0
  49. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/api/fastapi/__init__.py +0 -0
  50. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/api/fastapi/dependencies.py +0 -0
  51. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/api/fastapi/middleware.py +0 -0
  52. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/domain/__init__.py +0 -0
  53. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/domain/entities.py +0 -0
  54. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/domain/exceptions.py +0 -0
  55. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/domain/ports/key_provider.py +0 -0
  56. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/domain/ports/token_validator.py +0 -0
  57. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/domain/services/__init__.py +0 -0
  58. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/domain/services/authorization_service.py +0 -0
  59. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/src/open_shield/domain/services/claim_mapping.py +0 -0
  60. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/tests/integration/adapters/test_key_provider.py +0 -0
  61. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/tests/integration/adapters/test_token_validator.py +0 -0
  62. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/tests/integration/api/test_fastapi.py +0 -0
  63. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/tests/unit/adapters/test_config.py +0 -0
  64. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/tests/unit/domain/test_authorization_service.py +0 -0
  65. {open_shield_python-0.2.0 → open_shield_python-0.2.1}/tests/unit/domain/test_token_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-shield-python
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Vendor-agnostic authentication and authorization enforcement SDK
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "open-shield-python"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Vendor-agnostic authentication and authorization enforcement SDK"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,5 @@
1
+ from .key_provider import KeyProviderPort
2
+ from .tenant_resolver import TenantResolverPort
3
+ from .token_validator import TokenValidatorPort
4
+
5
+ __all__ = ["KeyProviderPort", "TenantResolverPort", "TokenValidatorPort"]
@@ -0,0 +1,37 @@
1
+ """Port for resolving tenant context from machine client credentials.
2
+
3
+ This port supports Case 3 (M2M clients) in the tenant resolution cascade:
4
+
5
+ 1. M2M client → lookup_client_tenant(client_id) → tenant_id
6
+ 2. Organization claim → organization_id → tenant_id
7
+ 3. Fallback → sub → tenant_id (individual user mode only)
8
+
9
+ Consumers implement this port to map client_id → tenant_id using their
10
+ own registry (database, config file, Logto Management API, etc.).
11
+ """
12
+
13
+ from abc import ABC, abstractmethod
14
+
15
+
16
+ class TenantResolverPort(ABC):
17
+ """Resolves tenant context for machine-to-machine (M2M) clients.
18
+
19
+ M2M tokens (client_credentials flow) don't carry organization claims.
20
+ This port allows consumers to map a ``client_id`` to a ``tenant_id``
21
+ using their own backend registry.
22
+
23
+ If no resolver is provided to ``TokenService``, M2M tokens fall through
24
+ to the organization claim or sub fallback.
25
+ """
26
+
27
+ @abstractmethod
28
+ def resolve_tenant(self, client_id: str) -> str | None:
29
+ """Look up the tenant for a machine client.
30
+
31
+ Args:
32
+ client_id: The OAuth2 client_id from the token.
33
+
34
+ Returns:
35
+ The tenant_id if found, or None to continue the cascade.
36
+ """
37
+ ...
@@ -1,27 +1,33 @@
1
1
  from open_shield.domain.entities import TenantContext, Token, User, UserContext
2
2
  from open_shield.domain.exceptions import TokenValidationError
3
- from open_shield.domain.ports import TokenValidatorPort
3
+ from open_shield.domain.ports import TenantResolverPort, TokenValidatorPort
4
4
  from open_shield.domain.services.claim_mapping import ClaimMapping
5
5
 
6
6
 
7
7
  class TokenService:
8
8
  """Domain service for orchestrating token validation and context extraction.
9
9
 
10
- Dependencies are injected via constructor (DIP).
10
+ Implements a 3-step tenant resolution cascade:
11
+ 1. M2M client → lookup via TenantResolverPort (if provided)
12
+ 2. Organization claim → configurable tenant_id_claim
13
+ 3. Fallback → sub (if tenant_fallback="sub")
11
14
 
12
15
  Args:
13
16
  validator: Port for JWT validation.
14
17
  claim_mapping: Configurable claim-to-field mapping.
15
- Defaults to standard OIDC claim names if not provided.
18
+ tenant_resolver: Optional port for M2M client→tenant lookup.
19
+ If not provided, M2M tokens fall through to the org claim.
16
20
  """
17
21
 
18
22
  def __init__(
19
23
  self,
20
24
  validator: TokenValidatorPort,
21
25
  claim_mapping: ClaimMapping | None = None,
26
+ tenant_resolver: TenantResolverPort | None = None,
22
27
  ):
23
28
  self.validator = validator
24
29
  self.claims = claim_mapping or ClaimMapping()
30
+ self.tenant_resolver = tenant_resolver
25
31
 
26
32
  def validate_and_extract(self, token_string: str) -> UserContext:
27
33
  """Validate a raw token string and extract the user context.
@@ -80,18 +86,49 @@ class TokenService:
80
86
  )
81
87
 
82
88
  def _extract_tenant(self, token: Token) -> TenantContext | None:
83
- """Extract tenant context from the token.
89
+ """Extract tenant context using a 3-step cascade.
84
90
 
85
- Uses the configured tenant_id_claim. Falls back to the
86
- configured tenant_fallback strategy if the claim is missing.
91
+ Resolution order:
92
+ 1. M2M client lookup If this is a client_credentials token
93
+ (sub == client_id) AND a TenantResolverPort is configured,
94
+ resolve tenant from the client registry.
95
+ 2. Organization claim — Read the configured tenant_id_claim
96
+ (e.g. organization_id, tid, org_id).
97
+ 3. Sub fallback — If tenant_fallback="sub", use the user's
98
+ subject as tenant_id (individual user mode).
99
+
100
+ Returns:
101
+ TenantContext if resolved, None if no tenant could be determined.
87
102
  """
88
- tid = token.claims.get(self.claims.tenant_id_claim)
103
+ claims = token.claims
104
+ sub = token.subject
105
+
106
+ # --- Step 1: M2M client → tenant lookup ---
107
+ client_id = claims.get("client_id", claims.get("azp"))
108
+ is_m2m = client_id and sub and sub == client_id
89
109
 
90
- if not tid and self.claims.tenant_fallback == "sub":
91
- tid = token.subject
110
+ if is_m2m and self.tenant_resolver:
111
+ resolved = self.tenant_resolver.resolve_tenant(client_id)
112
+ if resolved:
113
+ return TenantContext(
114
+ tenant_id=resolved,
115
+ metadata={"resolution": "m2m_lookup", "client_id": client_id},
116
+ )
92
117
 
118
+ # --- Step 2: Organization claim ---
119
+ tid = claims.get(self.claims.tenant_id_claim)
93
120
  if tid:
94
- return TenantContext(tenant_id=tid, metadata={})
121
+ return TenantContext(
122
+ tenant_id=tid,
123
+ metadata={"resolution": "claim", "claim": self.claims.tenant_id_claim},
124
+ )
125
+
126
+ # --- Step 3: Sub fallback (individual user mode) ---
127
+ if self.claims.tenant_fallback == "sub" and sub:
128
+ return TenantContext(
129
+ tenant_id=sub,
130
+ metadata={"resolution": "sub_fallback"},
131
+ )
95
132
 
96
133
  return None
97
134
 
@@ -1,7 +1,7 @@
1
1
  from typing import Any
2
2
 
3
3
  from open_shield.domain.entities import Token
4
- from open_shield.domain.ports import TokenValidatorPort
4
+ from open_shield.domain.ports import TenantResolverPort, TokenValidatorPort
5
5
  from open_shield.domain.services import TokenService
6
6
  from open_shield.domain.services.claim_mapping import ClaimMapping
7
7
 
@@ -152,3 +152,96 @@ def test_actor_type_azp_fallback() -> None:
152
152
 
153
153
  ctx = service.validate_and_extract("token")
154
154
  assert ctx.user.actor_type == "service"
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Tenant Resolution Cascade Tests
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ class _MockTenantResolver(TenantResolverPort):
163
+ """Mock tenant resolver for testing M2M lookup."""
164
+
165
+ def __init__(self, mapping: dict[str, str]) -> None:
166
+ self._mapping = mapping
167
+
168
+ def resolve_tenant(self, client_id: str) -> str | None:
169
+ return self._mapping.get(client_id)
170
+
171
+
172
+ def test_cascade_step1_m2m_resolver() -> None:
173
+ """Step 1: M2M token with resolver → tenant from registry."""
174
+ claims = {"sub": "client_abc", "client_id": "client_abc"}
175
+ resolver = _MockTenantResolver({"client_abc": "tenant_from_registry"})
176
+ service = TokenService(
177
+ _MockValidator(claims),
178
+ tenant_resolver=resolver,
179
+ )
180
+
181
+ ctx = service.validate_and_extract("token")
182
+ assert ctx.tenant is not None
183
+ assert ctx.tenant.tenant_id == "tenant_from_registry"
184
+ assert ctx.tenant.metadata["resolution"] == "m2m_lookup"
185
+
186
+
187
+ def test_cascade_step1_m2m_no_resolver_falls_through() -> None:
188
+ """Step 1 skipped: M2M token but no resolver → falls to step 2/3."""
189
+ claims = {"sub": "client_abc", "client_id": "client_abc", "tid": "org_123"}
190
+ service = TokenService(
191
+ _MockValidator(claims),
192
+ # No tenant_resolver provided
193
+ )
194
+
195
+ ctx = service.validate_and_extract("token")
196
+ assert ctx.tenant is not None
197
+ assert ctx.tenant.tenant_id == "org_123"
198
+ assert ctx.tenant.metadata["resolution"] == "claim"
199
+
200
+
201
+ def test_cascade_step1_resolver_returns_none_falls_through() -> None:
202
+ """Step 1: Resolver doesn't know this client → falls to step 2."""
203
+ claims = {
204
+ "sub": "unknown_client",
205
+ "client_id": "unknown_client",
206
+ "organization_id": "fallback_org",
207
+ }
208
+ resolver = _MockTenantResolver({}) # Empty — won't match
209
+ cm = ClaimMapping(tenant_id_claim="organization_id")
210
+ service = TokenService(
211
+ _MockValidator(claims),
212
+ claim_mapping=cm,
213
+ tenant_resolver=resolver,
214
+ )
215
+
216
+ ctx = service.validate_and_extract("token")
217
+ assert ctx.tenant is not None
218
+ assert ctx.tenant.tenant_id == "fallback_org"
219
+ assert ctx.tenant.metadata["resolution"] == "claim"
220
+
221
+
222
+ def test_cascade_step2_org_claim_priority() -> None:
223
+ """Step 2: Organization claim takes priority over sub fallback."""
224
+ claims = {"sub": "user_123", "organization_id": "team_abc"}
225
+ cm = ClaimMapping(
226
+ tenant_id_claim="organization_id",
227
+ tenant_fallback="sub", # Would use sub, but org claim wins
228
+ )
229
+ service = TokenService(_MockValidator(claims), claim_mapping=cm)
230
+
231
+ ctx = service.validate_and_extract("token")
232
+ assert ctx.tenant is not None
233
+ assert ctx.tenant.tenant_id == "team_abc"
234
+ assert ctx.tenant.metadata["resolution"] == "claim"
235
+
236
+
237
+ def test_cascade_step3_sub_fallback_metadata() -> None:
238
+ """Step 3: Sub fallback includes resolution metadata."""
239
+ claims = {"sub": "solo_user"}
240
+ cm = ClaimMapping(tenant_fallback="sub")
241
+ service = TokenService(_MockValidator(claims), claim_mapping=cm)
242
+
243
+ ctx = service.validate_and_extract("token")
244
+ assert ctx.tenant is not None
245
+ assert ctx.tenant.tenant_id == "solo_user"
246
+ assert ctx.tenant.metadata["resolution"] == "sub_fallback"
247
+
@@ -1,4 +0,0 @@
1
- from .key_provider import KeyProviderPort
2
- from .token_validator import TokenValidatorPort
3
-
4
- __all__ = ["KeyProviderPort", "TokenValidatorPort"]