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.
- duar_auth-1.0.0/.gitignore +44 -0
- duar_auth-1.0.0/PKG-INFO +17 -0
- duar_auth-1.0.0/pyproject.toml +40 -0
- duar_auth-1.0.0/src/duar_auth/__init__.py +30 -0
- duar_auth-1.0.0/src/duar_auth/_utils.py +21 -0
- duar_auth-1.0.0/src/duar_auth/auth.py +308 -0
- duar_auth-1.0.0/src/duar_auth/authz.py +78 -0
- duar_auth-1.0.0/src/duar_auth/authz_middleware.py +254 -0
- duar_auth-1.0.0/src/duar_auth/dependencies.py +131 -0
- duar_auth-1.0.0/src/duar_auth/duar.py +401 -0
- duar_auth-1.0.0/src/duar_auth/middleware.py +159 -0
- duar_auth-1.0.0/src/duar_auth/permissions.py +472 -0
- duar_auth-1.0.0/src/duar_auth/proxy.py +111 -0
- duar_auth-1.0.0/src/duar_auth/roles.py +103 -0
- duar_auth-1.0.0/src/duar_auth/types.py +107 -0
- duar_auth-1.0.0/tests/conftest.py +72 -0
- duar_auth-1.0.0/tests/test_auth.py +341 -0
- duar_auth-1.0.0/tests/test_authz_client.py +50 -0
- duar_auth-1.0.0/tests/test_authz_effective_scope.py +116 -0
- duar_auth-1.0.0/tests/test_authz_middleware.py +450 -0
- duar_auth-1.0.0/tests/test_dependencies.py +278 -0
- duar_auth-1.0.0/tests/test_duar_whoami.py +82 -0
- duar_auth-1.0.0/tests/test_m2m_verify.py +109 -0
- duar_auth-1.0.0/tests/test_middleware.py +237 -0
- duar_auth-1.0.0/tests/test_middleware_nonblocking.py +100 -0
- duar_auth-1.0.0/tests/test_mint_m2m.py +58 -0
- duar_auth-1.0.0/tests/test_permissions.py +303 -0
- duar_auth-1.0.0/tests/test_protect_airgapped.py +30 -0
- duar_auth-1.0.0/tests/test_proxy.py +139 -0
- duar_auth-1.0.0/tests/test_roles.py +75 -0
- duar_auth-1.0.0/tests/test_system_auth.py +23 -0
- 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/
|
duar_auth-1.0.0/PKG-INFO
ADDED
|
@@ -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()
|