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.
@@ -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
+ }
@@ -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, []))