duar-auth 1.0.0__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 (32) hide show
  1. duar_auth-1.0.0/.gitignore +44 -0
  2. duar_auth-1.0.0/PKG-INFO +17 -0
  3. duar_auth-1.0.0/pyproject.toml +40 -0
  4. duar_auth-1.0.0/src/duar_auth/__init__.py +30 -0
  5. duar_auth-1.0.0/src/duar_auth/_utils.py +21 -0
  6. duar_auth-1.0.0/src/duar_auth/auth.py +308 -0
  7. duar_auth-1.0.0/src/duar_auth/authz.py +78 -0
  8. duar_auth-1.0.0/src/duar_auth/authz_middleware.py +254 -0
  9. duar_auth-1.0.0/src/duar_auth/dependencies.py +131 -0
  10. duar_auth-1.0.0/src/duar_auth/duar.py +401 -0
  11. duar_auth-1.0.0/src/duar_auth/middleware.py +159 -0
  12. duar_auth-1.0.0/src/duar_auth/permissions.py +472 -0
  13. duar_auth-1.0.0/src/duar_auth/proxy.py +111 -0
  14. duar_auth-1.0.0/src/duar_auth/roles.py +103 -0
  15. duar_auth-1.0.0/src/duar_auth/types.py +107 -0
  16. duar_auth-1.0.0/tests/conftest.py +72 -0
  17. duar_auth-1.0.0/tests/test_auth.py +341 -0
  18. duar_auth-1.0.0/tests/test_authz_client.py +50 -0
  19. duar_auth-1.0.0/tests/test_authz_effective_scope.py +116 -0
  20. duar_auth-1.0.0/tests/test_authz_middleware.py +450 -0
  21. duar_auth-1.0.0/tests/test_dependencies.py +278 -0
  22. duar_auth-1.0.0/tests/test_duar_whoami.py +82 -0
  23. duar_auth-1.0.0/tests/test_m2m_verify.py +109 -0
  24. duar_auth-1.0.0/tests/test_middleware.py +237 -0
  25. duar_auth-1.0.0/tests/test_middleware_nonblocking.py +100 -0
  26. duar_auth-1.0.0/tests/test_mint_m2m.py +58 -0
  27. duar_auth-1.0.0/tests/test_permissions.py +303 -0
  28. duar_auth-1.0.0/tests/test_protect_airgapped.py +30 -0
  29. duar_auth-1.0.0/tests/test_proxy.py +139 -0
  30. duar_auth-1.0.0/tests/test_roles.py +75 -0
  31. duar_auth-1.0.0/tests/test_system_auth.py +23 -0
  32. duar_auth-1.0.0/tests/test_types.py +97 -0
@@ -0,0 +1,44 @@
1
+ # Brainstorm / visual-companion artifacts
2
+ .superpowers/
3
+
4
+ # Python-generated files
5
+ __pycache__/
6
+ *.py[oc]
7
+ build/
8
+ dist/
9
+ wheels/
10
+ *.egg-info
11
+
12
+ # Virtual environments
13
+ .venv
14
+
15
+ # Secrets
16
+ keys/
17
+ .env*
18
+ !.env.dev.example
19
+ !.env.prod.example
20
+ !demo-proxy/backend/.env.example
21
+ !demo-authz/backend/.env.example
22
+ !demo-authz/frontend/.env.example
23
+
24
+ # Node
25
+ node_modules/
26
+
27
+ # Archive
28
+ _archive/
29
+
30
+ # Coverage reports
31
+ .coverage
32
+ coverage.xml
33
+ htmlcov/
34
+
35
+ # MkDocs build output
36
+ site/
37
+
38
+ local-docs/
39
+
40
+ CLAUDE.md
41
+ .serena/.DS_Store
42
+
43
+ .DS_Store
44
+ .serena/
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: duar-auth
3
+ Version: 1.0.0
4
+ Summary: Duar SDK — JWT middleware, FastAPI dependencies, permission client
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: cryptography>=44.0.0
7
+ Requires-Dist: fastapi>=0.115.0
8
+ Requires-Dist: httpx>=0.28.0
9
+ Requires-Dist: pydantic>=2.10.0
10
+ Requires-Dist: pyjwt[crypto]>=2.10.0
11
+ Requires-Dist: starlette>=0.40.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
14
+ Requires-Dist: pytest-cov>=6.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Requires-Dist: respx>=0.22; extra == 'dev'
17
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "duar-auth"
3
+ version = "1.0.0"
4
+ description = "Duar SDK — JWT middleware, FastAPI dependencies, permission client"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "pyjwt[crypto]>=2.10.0",
8
+ "httpx>=0.28.0",
9
+ "cryptography>=44.0.0",
10
+ "pydantic>=2.10.0",
11
+ "starlette>=0.40.0",
12
+ "fastapi>=0.115.0",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.0",
18
+ "pytest-asyncio>=0.24",
19
+ "pytest-cov>=6.0",
20
+ "respx>=0.22",
21
+ "ruff>=0.9.0",
22
+ ]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/duar_auth"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ asyncio_mode = "auto"
34
+
35
+ [tool.ruff]
36
+ target-version = "py312"
37
+ line-length = 120
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "UP"]
@@ -0,0 +1,30 @@
1
+ from importlib.metadata import version
2
+
3
+ from duar_auth.auth import RequestAuth, SystemAuth
4
+ from duar_auth.authz import AuthzClient
5
+ from duar_auth.authz_middleware import AuthzMiddleware
6
+ from duar_auth.dependencies import get_token
7
+ from duar_auth.duar import Duar
8
+ from duar_auth.middleware import JWTAuthMiddleware
9
+ from duar_auth.permissions import PermissionClient
10
+ from duar_auth.proxy import create_proxy_router
11
+ from duar_auth.roles import RoleClient
12
+ from duar_auth.types import AuthenticatedUser, DuarError, WorkspaceContext
13
+
14
+ __version__ = version("duar-auth")
15
+ __all__ = [
16
+ "AuthenticatedUser",
17
+ "AuthzClient",
18
+ "AuthzMiddleware",
19
+ "JWTAuthMiddleware",
20
+ "PermissionClient",
21
+ "RequestAuth",
22
+ "RoleClient",
23
+ "Duar",
24
+ "DuarError",
25
+ "SystemAuth",
26
+ "WorkspaceContext",
27
+ "__version__",
28
+ "create_proxy_router",
29
+ "get_token",
30
+ ]
@@ -0,0 +1,21 @@
1
+ """Internal utilities for the Duar SDK."""
2
+
3
+ import logging
4
+ from urllib.parse import urlparse
5
+
6
+ _logger = logging.getLogger("duar_auth")
7
+
8
+ _SAFE_HOSTS = {"localhost", "127.0.0.1", "::1"}
9
+
10
+
11
+ def warn_if_insecure(url: str, context: str = "") -> None:
12
+ """Log a warning if the URL uses plain HTTP on a non-localhost host."""
13
+ parsed = urlparse(url)
14
+ if parsed.scheme == "http" and parsed.hostname not in _SAFE_HOSTS:
15
+ label = f" ({context})" if context else ""
16
+ _logger.warning(
17
+ "Duar SDK%s is connecting over plain HTTP to %s. "
18
+ "Use HTTPS in production to protect tokens and credentials.",
19
+ label,
20
+ parsed.hostname,
21
+ )
@@ -0,0 +1,308 @@
1
+ """Request-scoped auth context that bundles identity with authorization capabilities.
2
+
3
+ ``RequestAuth`` is created once per request by the framework layer (e.g. a FastAPI
4
+ dependency) and can be passed to any layer — including DDD use cases — as a plain
5
+ Python object. Receivers only need to define a ``Protocol`` matching the
6
+ attributes/methods they use; no SDK import required.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING
14
+
15
+ from duar_auth.types import AuthenticatedUser, DuarError
16
+
17
+ if TYPE_CHECKING:
18
+ from duar_auth.permissions import PermissionClient
19
+ from duar_auth.roles import RoleClient
20
+
21
+
22
+ @dataclass
23
+ class RequestAuth:
24
+ """Per-request auth context combining user identity with token-backed authorization.
25
+
26
+ The raw JWT token is stored privately and never exposed in ``repr``.
27
+ Authorization methods (``can``, ``check_action``, ``accessible``) use it
28
+ internally when calling Duar APIs.
29
+
30
+ Attributes:
31
+ user: The authenticated user from JWT claims.
32
+ """
33
+
34
+ user: AuthenticatedUser
35
+ _token: str = field(repr=False)
36
+ _permissions: PermissionClient | None = field(default=None, repr=False, compare=False)
37
+ _roles: RoleClient | None = field(default=None, repr=False, compare=False)
38
+ _request_cache: dict = field(default_factory=dict, repr=False, compare=False)
39
+
40
+ # -- Forwarded identity properties -----------------------------------------
41
+
42
+ @property
43
+ def user_id(self) -> uuid.UUID:
44
+ return self.user.user_id
45
+
46
+ @property
47
+ def workspace_id(self) -> uuid.UUID:
48
+ return self.user.workspace_id
49
+
50
+ @property
51
+ def workspace_role(self) -> str:
52
+ return self.user.workspace_role
53
+
54
+ @property
55
+ def email(self) -> str:
56
+ return self.user.email
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ return self.user.name
61
+
62
+ @property
63
+ def groups(self) -> list[uuid.UUID]:
64
+ return self.user.groups
65
+
66
+ @property
67
+ def org_id(self) -> uuid.UUID | None:
68
+ return self.user.org_id
69
+
70
+ @property
71
+ def org_slug(self) -> str | None:
72
+ return self.user.org_slug
73
+
74
+ @property
75
+ def org_is_public(self) -> bool:
76
+ return self.user.org_is_public
77
+
78
+ @property
79
+ def is_admin(self) -> bool:
80
+ return self.user.is_admin
81
+
82
+ @property
83
+ def is_editor(self) -> bool:
84
+ return self.user.is_editor
85
+
86
+ def has_role(self, minimum_role: str) -> bool:
87
+ """Check workspace role hierarchy. No network call."""
88
+ return self.user.has_role(minimum_role)
89
+
90
+ # -- Authorization (token hidden internally) -------------------------------
91
+
92
+ async def can(
93
+ self,
94
+ resource_type: str,
95
+ resource_id: uuid.UUID,
96
+ action: str,
97
+ ) -> bool:
98
+ """Check entity-level permission via Duar's Zanzibar API.
99
+
100
+ Results are deduplicated within the same request — calling ``can()``
101
+ twice with the same arguments makes only one HTTP call.
102
+ """
103
+ if self._permissions is None:
104
+ raise DuarError("PermissionClient not configured on this RequestAuth")
105
+ key = ("can", resource_type, resource_id, action)
106
+ if key not in self._request_cache:
107
+ self._request_cache[key] = await self._permissions.can(self._token, resource_type, resource_id, action)
108
+ return self._request_cache[key]
109
+
110
+ async def check_action(self, action: str) -> bool:
111
+ """Check RBAC action via Duar's role API.
112
+
113
+ Results are deduplicated within the same request.
114
+ """
115
+ if self._roles is None:
116
+ raise DuarError("RoleClient not configured on this RequestAuth")
117
+ key = ("check_action", action)
118
+ if key not in self._request_cache:
119
+ self._request_cache[key] = await self._roles.check_action(self._token, action, self.user.workspace_id)
120
+ return self._request_cache[key]
121
+
122
+ async def accessible(
123
+ self,
124
+ resource_type: str,
125
+ action: str,
126
+ limit: int | None = None,
127
+ ) -> tuple[list[uuid.UUID], bool]:
128
+ """Get accessible resource IDs for list filtering.
129
+
130
+ Returns ``(resource_ids, has_full_access)``. Results are deduplicated
131
+ within the same request.
132
+ """
133
+ if self._permissions is None:
134
+ raise DuarError("PermissionClient not configured on this RequestAuth")
135
+ key = ("accessible", resource_type, action, limit)
136
+ if key not in self._request_cache:
137
+ self._request_cache[key] = await self._permissions.accessible(
138
+ self._token,
139
+ resource_type,
140
+ action,
141
+ self.user.workspace_id,
142
+ limit,
143
+ )
144
+ return self._request_cache[key]
145
+
146
+ async def register_resource(
147
+ self,
148
+ resource_type: str,
149
+ resource_id: uuid.UUID,
150
+ visibility: str = "workspace",
151
+ ) -> dict:
152
+ """Register a new resource ACL (uses service key + user context)."""
153
+ if self._permissions is None:
154
+ raise DuarError("PermissionClient not configured on this RequestAuth")
155
+ return await self._permissions.register_resource(
156
+ resource_type=resource_type,
157
+ resource_id=resource_id,
158
+ workspace_id=self.user.workspace_id,
159
+ owner_id=self.user.user_id,
160
+ visibility=visibility,
161
+ )
162
+
163
+ async def share(
164
+ self,
165
+ resource_type: str,
166
+ resource_id: uuid.UUID,
167
+ grantee_type: str,
168
+ grantee_id: uuid.UUID,
169
+ permission: str = "view",
170
+ ) -> dict:
171
+ """Share a resource with a user or group."""
172
+ if self._permissions is None:
173
+ raise DuarError("PermissionClient not configured on this RequestAuth")
174
+ return await self._permissions.share(
175
+ self._token,
176
+ resource_type,
177
+ resource_id,
178
+ grantee_type,
179
+ grantee_id,
180
+ permission,
181
+ )
182
+
183
+ async def unshare(
184
+ self,
185
+ resource_type: str,
186
+ resource_id: uuid.UUID,
187
+ grantee_type: str,
188
+ grantee_id: uuid.UUID,
189
+ permission: str = "view",
190
+ ) -> dict:
191
+ """Revoke a share on a resource."""
192
+ if self._permissions is None:
193
+ raise DuarError("PermissionClient not configured on this RequestAuth")
194
+ return await self._permissions.unshare(
195
+ self._token,
196
+ resource_type,
197
+ resource_id,
198
+ grantee_type,
199
+ grantee_id,
200
+ permission,
201
+ )
202
+
203
+ async def update_visibility(
204
+ self,
205
+ resource_type: str,
206
+ resource_id: uuid.UUID,
207
+ visibility: str,
208
+ ) -> dict:
209
+ """Update resource visibility (private/workspace)."""
210
+ if self._permissions is None:
211
+ raise DuarError("PermissionClient not configured on this RequestAuth")
212
+ return await self._permissions.update_visibility(
213
+ self._token,
214
+ resource_type,
215
+ resource_id,
216
+ visibility,
217
+ )
218
+
219
+ async def get_resource_acl(
220
+ self,
221
+ resource_type: str,
222
+ resource_id: uuid.UUID,
223
+ ) -> dict:
224
+ """Get the full ACL record for a resource, including shares."""
225
+ if self._permissions is None:
226
+ raise DuarError("PermissionClient not configured on this RequestAuth")
227
+ return await self._permissions.get_resource_acl(resource_type, resource_id)
228
+
229
+ async def get_enriched_resource_acl(
230
+ self,
231
+ resource_type: str,
232
+ resource_id: uuid.UUID,
233
+ ) -> dict:
234
+ """Get ACL with user profiles resolved inline (names, emails)."""
235
+ if self._permissions is None:
236
+ raise DuarError("PermissionClient not configured on this RequestAuth")
237
+ return await self._permissions.get_enriched_resource_acl(resource_type, resource_id)
238
+
239
+ # -- Workspace / group helpers (auto-inject workspace_id from JWT) --------
240
+
241
+ async def search_members(
242
+ self,
243
+ query: str | None = None,
244
+ limit: int | None = None,
245
+ ) -> list[dict]:
246
+ """Search workspace members by name or email."""
247
+ if self._permissions is None:
248
+ raise DuarError("PermissionClient not configured on this RequestAuth")
249
+ return await self._permissions.search_workspace_members(
250
+ self._token,
251
+ self.user.workspace_id,
252
+ query,
253
+ limit,
254
+ )
255
+
256
+ async def list_members(
257
+ self,
258
+ limit: int | None = None,
259
+ ) -> list[dict]:
260
+ """List all members of the current workspace."""
261
+ if self._permissions is None:
262
+ raise DuarError("PermissionClient not configured on this RequestAuth")
263
+ return await self._permissions.search_workspace_members(
264
+ self._token,
265
+ self.user.workspace_id,
266
+ query=None,
267
+ limit=limit,
268
+ )
269
+
270
+ async def list_groups(self) -> list[dict]:
271
+ """List groups in the current workspace."""
272
+ if self._permissions is None:
273
+ raise DuarError("PermissionClient not configured on this RequestAuth")
274
+ return await self._permissions.list_groups(self._token, self.user.workspace_id)
275
+
276
+ async def get_group_members(self, group_id: uuid.UUID) -> list[dict]:
277
+ """List members of a group in the current workspace."""
278
+ if self._permissions is None:
279
+ raise DuarError("PermissionClient not configured on this RequestAuth")
280
+ return await self._permissions.get_group_members(
281
+ self._token,
282
+ self.user.workspace_id,
283
+ group_id,
284
+ )
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class SystemAuth:
289
+ """Per-request context for a no-user (machine-to-machine) in-realm call.
290
+
291
+ The no-user counterpart to :class:`RequestAuth`. It is produced by
292
+ ``Duar.verify_m2m_token`` after a ``type=m2m`` token (``aud=duar:m2m``)
293
+ passes Duar's RS256 signature + realm-scope checks. It carries service
294
+ identity only — never a user:
295
+
296
+ - ``caller``: the realm member that minted the token (server-stamped, for audit).
297
+ - ``svc``: the realm slug the token is scoped to (the shared ``effective_scope``).
298
+ - ``actions``: granted actions. ``["*"]`` is full in-realm trust (v1); a narrowed
299
+ list is honored by ``can`` with no shape change when least-privilege m2m ships.
300
+ """
301
+
302
+ caller: str
303
+ actions: list[str]
304
+ svc: str
305
+
306
+ def can(self, action: str) -> bool:
307
+ """Whether this system caller may perform ``action``. No network call."""
308
+ return "*" in self.actions or action in self.actions
@@ -0,0 +1,78 @@
1
+ """AuthZ mode client — resolve IdP tokens into authorization context."""
2
+
3
+ import uuid
4
+
5
+ import httpx
6
+
7
+ from duar_auth._utils import warn_if_insecure
8
+ from duar_auth.types import DuarError
9
+
10
+
11
+ class AuthzClient:
12
+ """Client for Duar's AuthZ mode endpoints.
13
+
14
+ Validates IdP tokens and retrieves authorization context
15
+ (workspace roles, RBAC actions, signed authz JWT).
16
+ """
17
+
18
+ def __init__(self, base_url: str, service_key: str):
19
+ self.base_url = base_url.rstrip("/")
20
+ self.service_key = service_key
21
+ self._client: httpx.AsyncClient | None = None
22
+ warn_if_insecure(self.base_url, "AuthzClient")
23
+
24
+ def __repr__(self) -> str:
25
+ return f"AuthzClient(base_url={self.base_url!r})"
26
+
27
+ async def _get_client(self) -> httpx.AsyncClient:
28
+ if self._client is None or self._client.is_closed:
29
+ self._client = httpx.AsyncClient(timeout=10.0)
30
+ return self._client
31
+
32
+ def _headers(self) -> dict[str, str]:
33
+ return {"X-Service-Key": self.service_key}
34
+
35
+ async def resolve(
36
+ self,
37
+ idp_token: str,
38
+ provider: str,
39
+ workspace_id: uuid.UUID | str | None = None,
40
+ nonce: str | None = None,
41
+ ) -> dict:
42
+ """Resolve an IdP token into authorization context.
43
+
44
+ Args:
45
+ idp_token: Raw token from the IdP (OIDC ID token or OAuth access token).
46
+ provider: IdP provider name ("google", "github", "entra_id").
47
+ workspace_id: Optional workspace to authorize for.
48
+ nonce: Optional replay-protection nonce. When provided, Duar
49
+ requires the IdP token's ``nonce`` claim (OIDC only) to match.
50
+
51
+ Returns:
52
+ Dict with user info. If workspace_id was provided, includes
53
+ authz_token and workspace. Otherwise includes workspaces list.
54
+ """
55
+ client = await self._get_client()
56
+ body: dict = {"idp_token": idp_token, "provider": provider}
57
+ if workspace_id:
58
+ body["workspace_id"] = str(workspace_id)
59
+ if nonce:
60
+ body["nonce"] = nonce
61
+ resp = await client.post(
62
+ f"{self.base_url}/authz/resolve",
63
+ json=body,
64
+ headers=self._headers(),
65
+ )
66
+ if resp.status_code != 200:
67
+ raise DuarError(f"Duar API error: {resp.status_code}", resp.status_code)
68
+ return resp.json()
69
+
70
+ async def close(self) -> None:
71
+ if self._client and not self._client.is_closed:
72
+ await self._client.aclose()
73
+
74
+ async def __aenter__(self):
75
+ return self
76
+
77
+ async def __aexit__(self, *args):
78
+ await self.close()