agentauthlayer 0.1.0__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.
- agent_auth/__init__.py +48 -0
- agent_auth/__main__.py +4 -0
- agent_auth/agents.py +40 -0
- agent_auth/audit.py +51 -0
- agent_auth/auth.py +36 -0
- agent_auth/cli.py +28 -0
- agent_auth/client.py +107 -0
- agent_auth/context.py +21 -0
- agent_auth/core.py +638 -0
- agent_auth/delegation.py +15 -0
- agent_auth/exceptions.py +72 -0
- agent_auth/models.py +72 -0
- agent_auth/policy.py +296 -0
- agent_auth/policy_service.py +176 -0
- agent_auth/principals.py +44 -0
- agent_auth/registry.py +90 -0
- agent_auth/session.py +135 -0
- agent_auth/storage.py +536 -0
- agent_auth/tokens.py +92 -0
- agent_auth/users.py +173 -0
- agentauthlayer-0.1.0.dist-info/METADATA +131 -0
- agentauthlayer-0.1.0.dist-info/RECORD +24 -0
- agentauthlayer-0.1.0.dist-info/WHEEL +5 -0
- agentauthlayer-0.1.0.dist-info/top_level.txt +1 -0
agent_auth/exceptions.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgentAuthError(Exception):
|
|
5
|
+
"""Base error for the agent_auth SDK."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthServiceError(AgentAuthError):
|
|
9
|
+
"""Raised when the remote Agent Auth service returns an error."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidTokenError(AuthServiceError):
|
|
13
|
+
"""Raised when a token is invalid or cannot be verified."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RevokedTokenError(AuthServiceError):
|
|
17
|
+
"""Raised when a token has been revoked or is inactive."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PermissionDeniedError(AuthServiceError):
|
|
21
|
+
"""Raised when a caller does not have permission to perform an action."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentNotFoundError(AuthServiceError):
|
|
25
|
+
"""Raised when the requested agent does not exist."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DuplicateAgentError(AgentAuthError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DuplicateUserError(AgentAuthError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidCredentialsError(AgentAuthError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InvalidResetTokenError(AgentAuthError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ScopeError(AgentAuthError):
|
|
45
|
+
def __init__(self, required: str, granted: list[str]):
|
|
46
|
+
self.required = required
|
|
47
|
+
self.granted = granted
|
|
48
|
+
super().__init__(f"Missing required scope '{required}'. Granted scopes: {granted}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TokenExpiredError(AgentAuthError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TokenInvalidError(AgentAuthError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TokenMissingError(AgentAuthError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TokenRevokedError(AgentAuthError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TokenTypeMismatchError(AgentAuthError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class UserNotFoundError(AgentAuthError):
|
|
72
|
+
pass
|
agent_auth/models.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""agent_auth data models — lightweight, reusable shared models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utc_now() -> datetime:
|
|
10
|
+
return datetime.now(timezone.utc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Agent:
|
|
15
|
+
agent_id: str
|
|
16
|
+
scopes: list[str] = field(default_factory=list)
|
|
17
|
+
metadata: dict = field(default_factory=dict)
|
|
18
|
+
name: str | None = None
|
|
19
|
+
owner: str | None = None
|
|
20
|
+
role: str | None = None
|
|
21
|
+
status: str = "active"
|
|
22
|
+
created_at: datetime | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class User:
|
|
27
|
+
id: str
|
|
28
|
+
email: str
|
|
29
|
+
name: str | None = None
|
|
30
|
+
role: str = "user"
|
|
31
|
+
created_at: datetime | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class TokenClaims:
|
|
36
|
+
agent_id: str
|
|
37
|
+
jti: str
|
|
38
|
+
scopes: list[str]
|
|
39
|
+
issued_at: datetime
|
|
40
|
+
expires_at: datetime
|
|
41
|
+
raw: dict = field(default_factory=dict)
|
|
42
|
+
token_type: str = "access"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class UserClaims:
|
|
47
|
+
user_id: str
|
|
48
|
+
email: str
|
|
49
|
+
role: str
|
|
50
|
+
jti: str
|
|
51
|
+
issued_at: datetime
|
|
52
|
+
expires_at: datetime
|
|
53
|
+
raw: dict = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class TokenRecord:
|
|
58
|
+
jti: str
|
|
59
|
+
principal_id: str
|
|
60
|
+
principal_type: str
|
|
61
|
+
scopes: list[str]
|
|
62
|
+
expires_at: datetime
|
|
63
|
+
status: str = 'active'
|
|
64
|
+
created_at: datetime = field(default_factory=utc_now)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def subject_id(self) -> str:
|
|
68
|
+
return self.principal_id
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def subject_type(self) -> str:
|
|
72
|
+
return self.principal_type
|
agent_auth/policy.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from agent_auth.delegation import DelegationGrant
|
|
9
|
+
from agent_auth.exceptions import ScopeError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class PermissionDefinition:
|
|
14
|
+
action: str
|
|
15
|
+
description: str = ''
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class PolicyStatement:
|
|
20
|
+
effect: str
|
|
21
|
+
actions: list[str]
|
|
22
|
+
resources: list[str] = field(default_factory=lambda: ['*'])
|
|
23
|
+
conditions: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
return {
|
|
27
|
+
'effect': self.effect,
|
|
28
|
+
'actions': self.actions,
|
|
29
|
+
'resources': self.resources,
|
|
30
|
+
'conditions': self.conditions,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, data: dict) -> PolicyStatement:
|
|
35
|
+
return cls(
|
|
36
|
+
effect=data.get('effect', 'DENY'),
|
|
37
|
+
actions=data.get('actions', []),
|
|
38
|
+
resources=data.get('resources', ['*']),
|
|
39
|
+
conditions=data.get('conditions', {}),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class RoleDefinition:
|
|
45
|
+
name: str
|
|
46
|
+
statements: list[PolicyStatement] = field(default_factory=list)
|
|
47
|
+
description: str = ''
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class PolicyRequest:
|
|
52
|
+
principal_id: str
|
|
53
|
+
action: str
|
|
54
|
+
resource: str | None = None
|
|
55
|
+
granted_scopes: list[str] = field(default_factory=list)
|
|
56
|
+
role: str | None = None
|
|
57
|
+
context: dict[str, str] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class PolicyDecision:
|
|
62
|
+
allowed: bool
|
|
63
|
+
reason: str
|
|
64
|
+
matched_role: str | None = None
|
|
65
|
+
matched_permission: str | None = None
|
|
66
|
+
resource: str | None = None
|
|
67
|
+
delegation_grant_id: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
DEFAULT_ROLES: dict[str, RoleDefinition] = {
|
|
71
|
+
'admin': RoleDefinition(
|
|
72
|
+
name='admin',
|
|
73
|
+
description='Full access to all systems',
|
|
74
|
+
statements=[PolicyStatement(effect='ALLOW', actions=['*'], resources=['*'])],
|
|
75
|
+
),
|
|
76
|
+
'research_agent': RoleDefinition(
|
|
77
|
+
name='research_agent',
|
|
78
|
+
description='Can research and read docs, with restricted LLM usage',
|
|
79
|
+
statements=[
|
|
80
|
+
PolicyStatement(effect='ALLOW', actions=['docs.read', 'tool.search_web'], resources=['*']),
|
|
81
|
+
PolicyStatement(
|
|
82
|
+
effect='ALLOW',
|
|
83
|
+
actions=['tool.call_llm'],
|
|
84
|
+
resources=['model/*'],
|
|
85
|
+
conditions={'StringEquals': {'environment': 'approved'}},
|
|
86
|
+
),
|
|
87
|
+
],
|
|
88
|
+
),
|
|
89
|
+
'ops_agent': RoleDefinition(
|
|
90
|
+
name='ops_agent',
|
|
91
|
+
description='Can run workflows and read audits',
|
|
92
|
+
statements=[PolicyStatement(effect='ALLOW', actions=['workflow.run', 'audit.read'], resources=['*'])],
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
SCOPE_TO_ACTIONS: dict[str, list[str]] = {
|
|
97
|
+
'admin_agents': ['agent.create', 'agent.update_scopes'],
|
|
98
|
+
'admin_tokens': ['token.revoke'],
|
|
99
|
+
'read_audit': ['audit.read'],
|
|
100
|
+
'call_llm': ['tool.call_llm'],
|
|
101
|
+
'run_graph': ['workflow.run'],
|
|
102
|
+
'search_web': ['tool.search_web'],
|
|
103
|
+
'send_email': ['tool.send_email'],
|
|
104
|
+
'read_only': ['agent.read'],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_scope(granted: list[str], required: str) -> None:
|
|
109
|
+
if '*' in granted:
|
|
110
|
+
return
|
|
111
|
+
if required not in granted:
|
|
112
|
+
raise ScopeError(required, granted)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def check_scopes(granted: list[str], required: list[str]) -> None:
|
|
116
|
+
for scope in required:
|
|
117
|
+
check_scope(granted, scope)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _match_string(pattern: str, target: str) -> bool:
|
|
121
|
+
if pattern == '*':
|
|
122
|
+
return True
|
|
123
|
+
return fnmatch.fnmatch(target, pattern)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PolicyEvaluator:
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
roles: dict[str, RoleDefinition] | None = None,
|
|
130
|
+
scope_to_actions: dict[str, list[str]] | None = None,
|
|
131
|
+
active_delegations: list[DelegationGrant] | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
self._roles = roles if roles is not None else DEFAULT_ROLES
|
|
134
|
+
self._scope_to_actions = scope_to_actions or SCOPE_TO_ACTIONS
|
|
135
|
+
self._active_delegations = active_delegations or []
|
|
136
|
+
|
|
137
|
+
self._permissions = {}
|
|
138
|
+
for role in self._roles.values():
|
|
139
|
+
for stmt in role.statements:
|
|
140
|
+
for action in stmt.actions:
|
|
141
|
+
if action != '*':
|
|
142
|
+
self._permissions.setdefault(action, PermissionDefinition(action=action))
|
|
143
|
+
for actions in self._scope_to_actions.values():
|
|
144
|
+
for action in actions:
|
|
145
|
+
self._permissions.setdefault(action, PermissionDefinition(action=action))
|
|
146
|
+
|
|
147
|
+
def _check_delegations(self, request: PolicyRequest) -> PolicyDecision | None:
|
|
148
|
+
from datetime import datetime, timezone
|
|
149
|
+
|
|
150
|
+
now = datetime.now(timezone.utc)
|
|
151
|
+
for grant in self._active_delegations:
|
|
152
|
+
if grant.delegatee_id != request.principal_id:
|
|
153
|
+
continue
|
|
154
|
+
if grant.expires_at and grant.expires_at < now:
|
|
155
|
+
continue
|
|
156
|
+
if grant.action != request.action:
|
|
157
|
+
continue
|
|
158
|
+
if grant.resource and request.resource and not request.resource.startswith(grant.resource):
|
|
159
|
+
continue
|
|
160
|
+
context_match = True
|
|
161
|
+
for k, v in grant.context.items():
|
|
162
|
+
if request.context.get(k) != v:
|
|
163
|
+
context_match = False
|
|
164
|
+
break
|
|
165
|
+
if context_match:
|
|
166
|
+
return PolicyDecision(
|
|
167
|
+
allowed=True,
|
|
168
|
+
reason='allowed_by_delegation',
|
|
169
|
+
matched_role=None,
|
|
170
|
+
matched_permission=request.action,
|
|
171
|
+
resource=request.resource,
|
|
172
|
+
delegation_grant_id=grant.grant_id,
|
|
173
|
+
)
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def _evaluate_conditions(self, conditions: dict[str, dict[str, Any]], context: dict[str, str]) -> bool:
|
|
177
|
+
if not conditions:
|
|
178
|
+
return True
|
|
179
|
+
for operator, kv in conditions.items():
|
|
180
|
+
if operator == 'StringEquals':
|
|
181
|
+
for k, v in kv.items():
|
|
182
|
+
if context.get(k) != v:
|
|
183
|
+
return False
|
|
184
|
+
elif operator == 'StringNotEquals':
|
|
185
|
+
for k, v in kv.items():
|
|
186
|
+
if context.get(k) == v:
|
|
187
|
+
return False
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
def _statement_matches(self, stmt: PolicyStatement, request: PolicyRequest) -> bool:
|
|
191
|
+
if not any(_match_string(act, request.action) for act in stmt.actions):
|
|
192
|
+
return False
|
|
193
|
+
req_res = request.resource or '*'
|
|
194
|
+
if not any(_match_string(res, req_res) for res in stmt.resources):
|
|
195
|
+
return False
|
|
196
|
+
if not self._evaluate_conditions(stmt.conditions, request.context):
|
|
197
|
+
return False
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
def _scope_allows(self, request: PolicyRequest) -> bool:
|
|
201
|
+
allowed_actions: set[str] = set()
|
|
202
|
+
for scope in request.granted_scopes:
|
|
203
|
+
allowed_actions.update(self._scope_to_actions.get(scope, []))
|
|
204
|
+
return any(_match_string(action, request.action) for action in allowed_actions)
|
|
205
|
+
|
|
206
|
+
def evaluate(self, request: PolicyRequest) -> PolicyDecision:
|
|
207
|
+
delegation_decision = self._check_delegations(request)
|
|
208
|
+
if delegation_decision:
|
|
209
|
+
return delegation_decision
|
|
210
|
+
|
|
211
|
+
role_name = request.role
|
|
212
|
+
role_def = self._roles.get(role_name)
|
|
213
|
+
if not role_def and role_name in DEFAULT_ROLES:
|
|
214
|
+
role_def = DEFAULT_ROLES[role_name]
|
|
215
|
+
|
|
216
|
+
if not role_def:
|
|
217
|
+
if self._scope_allows(request):
|
|
218
|
+
return PolicyDecision(
|
|
219
|
+
allowed=True,
|
|
220
|
+
reason='allowed_by_scope',
|
|
221
|
+
matched_role=request.role,
|
|
222
|
+
matched_permission=request.action,
|
|
223
|
+
resource=request.resource,
|
|
224
|
+
)
|
|
225
|
+
return PolicyDecision(
|
|
226
|
+
allowed=False,
|
|
227
|
+
reason='missing_permission',
|
|
228
|
+
matched_role=request.role,
|
|
229
|
+
resource=request.resource,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
for stmt in role_def.statements:
|
|
233
|
+
if stmt.effect == 'DENY' and self._statement_matches(stmt, request):
|
|
234
|
+
return PolicyDecision(
|
|
235
|
+
allowed=False,
|
|
236
|
+
reason='explicit_deny',
|
|
237
|
+
matched_role=request.role,
|
|
238
|
+
matched_permission=request.action,
|
|
239
|
+
resource=request.resource,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
for stmt in role_def.statements:
|
|
243
|
+
if stmt.effect == 'ALLOW' and self._statement_matches(stmt, request):
|
|
244
|
+
return PolicyDecision(
|
|
245
|
+
allowed=True,
|
|
246
|
+
reason='allowed_by_policy',
|
|
247
|
+
matched_role=request.role,
|
|
248
|
+
matched_permission=request.action,
|
|
249
|
+
resource=request.resource,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if self._scope_allows(request):
|
|
253
|
+
return PolicyDecision(
|
|
254
|
+
allowed=True,
|
|
255
|
+
reason='allowed_by_scope',
|
|
256
|
+
matched_role=request.role,
|
|
257
|
+
matched_permission=request.action,
|
|
258
|
+
resource=request.resource,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return PolicyDecision(
|
|
262
|
+
allowed=False,
|
|
263
|
+
reason='implicit_deny',
|
|
264
|
+
matched_role=request.role,
|
|
265
|
+
resource=request.resource,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def require_permission(action: str, resource: str | None = None):
|
|
270
|
+
def decorator(func: Callable):
|
|
271
|
+
@wraps(func)
|
|
272
|
+
def wrapper(*args, **kwargs):
|
|
273
|
+
agent_id = kwargs.get('agent_id') or (args[0] if args else None)
|
|
274
|
+
role = kwargs.get('role')
|
|
275
|
+
context = kwargs.get('context', {}) or {}
|
|
276
|
+
granted_scopes = kwargs.get('granted_scopes', []) or []
|
|
277
|
+
resource_value = kwargs.get('resource') or resource or '*'
|
|
278
|
+
|
|
279
|
+
evaluator = PolicyEvaluator()
|
|
280
|
+
decision = evaluator.evaluate(
|
|
281
|
+
PolicyRequest(
|
|
282
|
+
principal_id=agent_id or 'unknown',
|
|
283
|
+
action=action,
|
|
284
|
+
resource=resource_value,
|
|
285
|
+
granted_scopes=granted_scopes,
|
|
286
|
+
role=role,
|
|
287
|
+
context=context,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
if not decision.allowed:
|
|
291
|
+
raise PermissionError(f'Access Denied: {decision.reason}')
|
|
292
|
+
return func(*args, **kwargs)
|
|
293
|
+
|
|
294
|
+
return wrapper
|
|
295
|
+
|
|
296
|
+
return decorator
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from agent_auth.delegation import DelegationGrant
|
|
7
|
+
from agent_auth.policy import DEFAULT_ROLES, PermissionDefinition, PolicyDecision, PolicyEvaluator, PolicyRequest, PolicyStatement, RoleDefinition
|
|
8
|
+
from agent_auth.principals import Principal, user_scopes_for_role
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RoleStore(Protocol):
|
|
12
|
+
def save(self, role: RoleDefinition) -> RoleDefinition:
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
def get(self, name: str) -> RoleDefinition | None:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def list_roles(self) -> list[RoleDefinition]:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PermissionStore(Protocol):
|
|
23
|
+
def save(self, permission: PermissionDefinition) -> PermissionDefinition:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def list_permissions(self) -> list[PermissionDefinition]:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DelegationStore(Protocol):
|
|
31
|
+
def save(self, grant: DelegationGrant) -> DelegationGrant:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def list_for_delegatee(self, delegatee_id: str) -> list[DelegationGrant]:
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CorePolicyService:
|
|
39
|
+
"""Core policy, permission, and delegation orchestration."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
role_store: RoleStore,
|
|
44
|
+
delegation_store: DelegationStore,
|
|
45
|
+
permission_store: PermissionStore,
|
|
46
|
+
constraint_store=None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.role_store = role_store
|
|
49
|
+
self.delegation_store = delegation_store
|
|
50
|
+
self.permission_store = permission_store
|
|
51
|
+
self.constraint_store = constraint_store
|
|
52
|
+
|
|
53
|
+
def list_roles(self, include_defaults: bool = False) -> list[RoleDefinition]:
|
|
54
|
+
persisted_roles = self.role_store.list_roles()
|
|
55
|
+
if not include_defaults:
|
|
56
|
+
return persisted_roles
|
|
57
|
+
custom_roles = {role.name: role for role in persisted_roles}
|
|
58
|
+
return list({**DEFAULT_ROLES, **custom_roles}.values())
|
|
59
|
+
|
|
60
|
+
def get_effective_roles(self) -> dict[str, RoleDefinition]:
|
|
61
|
+
persisted_roles = {role.name: role for role in self.role_store.list_roles()}
|
|
62
|
+
return {**DEFAULT_ROLES, **persisted_roles}
|
|
63
|
+
|
|
64
|
+
def upsert_role(self, role: RoleDefinition) -> RoleDefinition:
|
|
65
|
+
return self.role_store.save(role)
|
|
66
|
+
|
|
67
|
+
def build_role(self, name: str, statements: list[PolicyStatement], description: str = '', permissions: list[str] | None = None) -> RoleDefinition:
|
|
68
|
+
effective_statements = statements
|
|
69
|
+
if permissions and not effective_statements:
|
|
70
|
+
effective_statements = [PolicyStatement(effect='ALLOW', actions=permissions, resources=['*'])]
|
|
71
|
+
return RoleDefinition(name=name, statements=effective_statements, description=description)
|
|
72
|
+
|
|
73
|
+
def list_permissions(self, include_defaults: bool = True) -> list[PermissionDefinition]:
|
|
74
|
+
seen: dict[str, PermissionDefinition] = {
|
|
75
|
+
permission.action: permission for permission in self.permission_store.list_permissions()
|
|
76
|
+
}
|
|
77
|
+
if include_defaults:
|
|
78
|
+
for role in self.get_effective_roles().values():
|
|
79
|
+
for statement in role.statements:
|
|
80
|
+
for action in statement.actions:
|
|
81
|
+
if action != '*' and action not in seen:
|
|
82
|
+
seen[action] = PermissionDefinition(action=action, description='')
|
|
83
|
+
return list(seen.values())
|
|
84
|
+
|
|
85
|
+
def upsert_permission(self, permission: PermissionDefinition) -> PermissionDefinition:
|
|
86
|
+
return self.permission_store.save(permission)
|
|
87
|
+
|
|
88
|
+
def sync_permissions(self, permissions: list[PermissionDefinition]) -> int:
|
|
89
|
+
for permission in permissions:
|
|
90
|
+
self.upsert_permission(permission)
|
|
91
|
+
return len(permissions)
|
|
92
|
+
|
|
93
|
+
def grant_delegation(self, grant: DelegationGrant) -> DelegationGrant:
|
|
94
|
+
return self.delegation_store.save(grant)
|
|
95
|
+
|
|
96
|
+
def list_delegations_for_delegatee(self, delegatee_id: str) -> list[DelegationGrant]:
|
|
97
|
+
return self.delegation_store.list_for_delegatee(delegatee_id)
|
|
98
|
+
|
|
99
|
+
def evaluate(self, request: PolicyRequest) -> PolicyDecision:
|
|
100
|
+
evaluator = PolicyEvaluator(
|
|
101
|
+
roles=self.get_effective_roles(),
|
|
102
|
+
active_delegations=self.delegation_store.list_for_delegatee(request.principal_id),
|
|
103
|
+
)
|
|
104
|
+
return evaluator.evaluate(request)
|
|
105
|
+
|
|
106
|
+
def build_request(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
principal_id: str,
|
|
110
|
+
action: str,
|
|
111
|
+
resource: str | None = None,
|
|
112
|
+
granted_scopes: list[str] | None = None,
|
|
113
|
+
role: str | None = None,
|
|
114
|
+
context: dict[str, str] | None = None,
|
|
115
|
+
) -> PolicyRequest:
|
|
116
|
+
return PolicyRequest(
|
|
117
|
+
principal_id=principal_id,
|
|
118
|
+
action=action,
|
|
119
|
+
resource=resource,
|
|
120
|
+
granted_scopes=granted_scopes or [],
|
|
121
|
+
role=role,
|
|
122
|
+
context=context or {},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def grant_delegation_if_allowed(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
delegator: Principal,
|
|
129
|
+
grant_id: str,
|
|
130
|
+
delegatee_id: str,
|
|
131
|
+
action: str,
|
|
132
|
+
resource: str | None = None,
|
|
133
|
+
expires_in_seconds: int | None = None,
|
|
134
|
+
context: dict[str, str] | None = None,
|
|
135
|
+
) -> tuple[DelegationGrant | None, PolicyDecision]:
|
|
136
|
+
delegator_role = delegator.role
|
|
137
|
+
if delegator_role is None and 'admin_agents' in delegator.scopes:
|
|
138
|
+
delegator_role = 'admin'
|
|
139
|
+
decision = self.evaluate(
|
|
140
|
+
self.build_request(
|
|
141
|
+
principal_id=delegator.principal_id,
|
|
142
|
+
action=action,
|
|
143
|
+
resource=resource,
|
|
144
|
+
granted_scopes=delegator.scopes,
|
|
145
|
+
role=delegator_role,
|
|
146
|
+
context=context or {},
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
if not decision.allowed:
|
|
150
|
+
return None, decision
|
|
151
|
+
expires_at = None
|
|
152
|
+
if expires_in_seconds:
|
|
153
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)
|
|
154
|
+
grant = DelegationGrant(
|
|
155
|
+
grant_id=grant_id,
|
|
156
|
+
delegator_id=delegator.principal_id,
|
|
157
|
+
delegatee_id=delegatee_id,
|
|
158
|
+
action=action,
|
|
159
|
+
resource=resource,
|
|
160
|
+
expires_at=expires_at,
|
|
161
|
+
context=context or {},
|
|
162
|
+
)
|
|
163
|
+
return self.grant_delegation(grant), decision
|
|
164
|
+
|
|
165
|
+
def effective_access_for_user(self, *, user_id: str, email: str, role: str):
|
|
166
|
+
scopes = user_scopes_for_role(role)
|
|
167
|
+
return {
|
|
168
|
+
'user_id': user_id,
|
|
169
|
+
'email': email,
|
|
170
|
+
'role': role,
|
|
171
|
+
'scopes': scopes,
|
|
172
|
+
'can_manage_users': 'admin_users' in scopes,
|
|
173
|
+
'can_manage_agents': 'admin_agents' in scopes,
|
|
174
|
+
'can_manage_tokens': 'admin_tokens' in scopes,
|
|
175
|
+
'can_read_audit': 'read_audit' in scopes,
|
|
176
|
+
}
|
agent_auth/principals.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
PrincipalType = Literal['user', 'agent', 'system']
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class Principal:
|
|
11
|
+
principal_id: str
|
|
12
|
+
principal_type: PrincipalType
|
|
13
|
+
role: str | None = None
|
|
14
|
+
scopes: list[str] = field(default_factory=list)
|
|
15
|
+
email: str | None = None
|
|
16
|
+
owner: str | None = None
|
|
17
|
+
status: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class UserPrincipal(Principal):
|
|
22
|
+
principal_type: PrincipalType = 'user'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class AgentPrincipal(Principal):
|
|
27
|
+
principal_type: PrincipalType = 'agent'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class SystemPrincipal(Principal):
|
|
32
|
+
principal_type: PrincipalType = 'system'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_USER_ROLE_SCOPES: dict[str, list[str]] = {
|
|
36
|
+
'super_admin': ['admin_users', 'admin_agents', 'admin_tokens', 'read_audit'],
|
|
37
|
+
'admin': ['admin_users', 'admin_agents', 'admin_tokens', 'read_audit'],
|
|
38
|
+
'auditor': ['read_audit'],
|
|
39
|
+
'viewer': ['read_audit'],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def user_scopes_for_role(role: str) -> list[str]:
|
|
44
|
+
return list(DEFAULT_USER_ROLE_SCOPES.get(role, []))
|