sf-smartdelegate 0.3.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.
- sf_smartdelegate/__init__.py +32 -0
- sf_smartdelegate/__main__.py +3 -0
- sf_smartdelegate/_version.py +1 -0
- sf_smartdelegate/audit.py +113 -0
- sf_smartdelegate/backend.py +84 -0
- sf_smartdelegate/bridges/__init__.py +56 -0
- sf_smartdelegate/bridges/__main__.py +6 -0
- sf_smartdelegate/bridges/aws.py +293 -0
- sf_smartdelegate/bridges/aws_identity_center.py +105 -0
- sf_smartdelegate/bridges/azure.py +177 -0
- sf_smartdelegate/bridges/ceiling.py +107 -0
- sf_smartdelegate/bridges/claims.py +221 -0
- sf_smartdelegate/bridges/cli.py +257 -0
- sf_smartdelegate/bridges/cloudmap.py +247 -0
- sf_smartdelegate/bridges/directory.py +386 -0
- sf_smartdelegate/bridges/entra.py +141 -0
- sf_smartdelegate/bridges/gcp.py +190 -0
- sf_smartdelegate/bridges/google.py +90 -0
- sf_smartdelegate/bridges/http.py +259 -0
- sf_smartdelegate/bridges/keycloak.py +152 -0
- sf_smartdelegate/bridges/okta.py +71 -0
- sf_smartdelegate/bridges/scim.py +102 -0
- sf_smartdelegate/bridges/sigv4.py +72 -0
- sf_smartdelegate/bundle.py +265 -0
- sf_smartdelegate/canon.py +24 -0
- sf_smartdelegate/cedar.py +489 -0
- sf_smartdelegate/cli.py +416 -0
- sf_smartdelegate/client.py +60 -0
- sf_smartdelegate/conformance.py +87 -0
- sf_smartdelegate/core.py +164 -0
- sf_smartdelegate/daemon.py +139 -0
- sf_smartdelegate/engine.py +1086 -0
- sf_smartdelegate/errors.py +20 -0
- sf_smartdelegate/example_bundle/catalog.json +38 -0
- sf_smartdelegate/example_bundle/entities.json +15 -0
- sf_smartdelegate/example_bundle/policies/billing.cedar +26 -0
- sf_smartdelegate/example_bundle/policies/crm.cedar +38 -0
- sf_smartdelegate/example_bundle/policies/delegation.cedar +23 -0
- sf_smartdelegate/example_bundle/policies/hr.cedar +11 -0
- sf_smartdelegate/example_bundle/policies/roles.cedar +15 -0
- sf_smartdelegate/example_bundle/policies.json +1394 -0
- sf_smartdelegate/example_bundle/roles.json +44 -0
- sf_smartdelegate/example_bundle/tests/basics.json +158 -0
- sf_smartdelegate/fields.py +122 -0
- sf_smartdelegate/guard.py +79 -0
- sf_smartdelegate/httpproxy.py +525 -0
- sf_smartdelegate/mcpproxy.py +370 -0
- sf_smartdelegate/models.py +133 -0
- sf_smartdelegate/sql.py +173 -0
- sf_smartdelegate/state.py +63 -0
- sf_smartdelegate/tokens.py +299 -0
- sf_smartdelegate-0.3.0.dist-info/METADATA +395 -0
- sf_smartdelegate-0.3.0.dist-info/RECORD +58 -0
- sf_smartdelegate-0.3.0.dist-info/WHEEL +5 -0
- sf_smartdelegate-0.3.0.dist-info/entry_points.txt +2 -0
- sf_smartdelegate-0.3.0.dist-info/licenses/LICENSE +176 -0
- sf_smartdelegate-0.3.0.dist-info/licenses/NOTICE +23 -0
- sf_smartdelegate-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""SmartDelegate: identity and access management for LLM agents.
|
|
2
|
+
|
|
3
|
+
This package is the reference implementation. Other languages are ports of it
|
|
4
|
+
and are held to the same conformance vectors (spec/conformance).
|
|
5
|
+
|
|
6
|
+
from sf_smartdelegate import Engine, Guard
|
|
7
|
+
|
|
8
|
+
iam = Engine({"bundle_dir": "bundle", "state_dir": "state"})
|
|
9
|
+
guard = Guard(iam, token=lambda: current_task_token)
|
|
10
|
+
|
|
11
|
+
@guard.tool(action="read", resource="crm.customer", id="customer_id")
|
|
12
|
+
def get_customer(customer_id): ...
|
|
13
|
+
|
|
14
|
+
The short way in is `sf_smartdelegate.core`: `delegate` (a human lends an agent a
|
|
15
|
+
role for one task), `narrow` (a sub-agent gets less) and `check`.
|
|
16
|
+
|
|
17
|
+
`Engine` decides in-process. `DaemonClient` asks a running `smartdelegate serve`.
|
|
18
|
+
Both have the same methods. In-process checks bind an agent that cooperates;
|
|
19
|
+
the boundary an agent cannot skip is a proxy (`smartdelegate http-proxy`,
|
|
20
|
+
`smartdelegate mcp-proxy`). See docs/LIMITATIONS.md.
|
|
21
|
+
"""
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
from .backend import SmartDelegateError, Backend # noqa: E402
|
|
25
|
+
from .client import DaemonClient # noqa: E402
|
|
26
|
+
from .engine import Engine # noqa: E402
|
|
27
|
+
from .errors import EngineError # noqa: E402
|
|
28
|
+
from .guard import ApprovalRequired, Denied, Guard # noqa: E402
|
|
29
|
+
from .sql import SqlGuard # noqa: E402
|
|
30
|
+
from . import core, models # noqa: E402
|
|
31
|
+
|
|
32
|
+
__all__ = ["Engine", "DaemonClient", "Guard", "SqlGuard", "Denied", "ApprovalRequired", "SmartDelegateError", "EngineError", "Backend"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Append-only, hash-chained decision log (JSON Lines).
|
|
2
|
+
|
|
3
|
+
record.hash = sha256(prev_hash + canonical_json(record without "hash")).
|
|
4
|
+
Editing, deleting or reordering any line breaks every hash after it.
|
|
5
|
+
Truncating the tail is only detectable against a head hash kept elsewhere.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .canon import canon, sha256_hex
|
|
14
|
+
from .errors import EngineError
|
|
15
|
+
|
|
16
|
+
GENESIS = "0" * 64
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def verify(path: Path) -> dict:
|
|
20
|
+
"""Recompute the whole chain. Raises audit_broken naming the first bad line."""
|
|
21
|
+
prev, n = GENESIS, 0
|
|
22
|
+
try:
|
|
23
|
+
raw = Path(path).read_bytes()
|
|
24
|
+
except OSError as e:
|
|
25
|
+
raise EngineError("audit_io", f"cannot read audit log: {e.strerror or e}") from None
|
|
26
|
+
try:
|
|
27
|
+
text = raw.decode("utf-8")
|
|
28
|
+
except UnicodeDecodeError:
|
|
29
|
+
raise EngineError("audit_broken", "the audit log is not UTF-8") from None
|
|
30
|
+
for lineno, line in enumerate(text.split("\n"), 1):
|
|
31
|
+
if not line.strip():
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
rec = json.loads(line)
|
|
35
|
+
assert isinstance(rec, dict)
|
|
36
|
+
except Exception:
|
|
37
|
+
raise EngineError("audit_broken", f"line {lineno}: not JSON") from None
|
|
38
|
+
claimed = rec.pop("hash", "")
|
|
39
|
+
if rec.get("prev") != prev:
|
|
40
|
+
raise EngineError("audit_broken", f"line {lineno}: prev hash does not match the line before")
|
|
41
|
+
if rec.get("seq") != n + 1:
|
|
42
|
+
raise EngineError("audit_broken", f"line {lineno}: sequence number is not {n + 1}")
|
|
43
|
+
if sha256_hex((prev + canon(rec)).encode("utf-8")) != claimed:
|
|
44
|
+
raise EngineError("audit_broken", f"line {lineno}: content does not match its hash")
|
|
45
|
+
prev = claimed
|
|
46
|
+
n += 1
|
|
47
|
+
return {"ok": True, "records": n, "head": prev}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def tail(path: Path, n: int) -> list:
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return []
|
|
53
|
+
# split on "\n" only: canonical JSON writes U+2028, U+0085 and friends
|
|
54
|
+
# as themselves, and str.splitlines() would cut a record in two there
|
|
55
|
+
lines = [l for l in path.read_bytes().decode("utf-8", "replace").split("\n") if l.strip()]
|
|
56
|
+
out = []
|
|
57
|
+
for l in lines[max(0, len(lines) - n):]:
|
|
58
|
+
try:
|
|
59
|
+
out.append(json.loads(l))
|
|
60
|
+
except ValueError:
|
|
61
|
+
pass
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AuditLog:
|
|
66
|
+
def __init__(self, path: Path):
|
|
67
|
+
self.path = Path(path)
|
|
68
|
+
self.seq, self.head = 0, GENESIS
|
|
69
|
+
if self.path.exists():
|
|
70
|
+
v = verify(self.path)
|
|
71
|
+
self.seq, self.head = v["records"], v["head"]
|
|
72
|
+
self.len = self.path.stat().st_size if self.path.exists() else 0
|
|
73
|
+
|
|
74
|
+
def _resync(self) -> None:
|
|
75
|
+
"""Chain onto the last record if another process appended. The engine
|
|
76
|
+
holds the state-directory lock while this runs."""
|
|
77
|
+
try:
|
|
78
|
+
size = self.path.stat().st_size
|
|
79
|
+
except OSError:
|
|
80
|
+
size = 0
|
|
81
|
+
if size == self.len:
|
|
82
|
+
return
|
|
83
|
+
if size == 0:
|
|
84
|
+
raise EngineError("audit_broken", "the audit log was truncated while in use")
|
|
85
|
+
try:
|
|
86
|
+
with open(self.path, "rb") as f:
|
|
87
|
+
f.seek(max(0, size - (1 << 20)))
|
|
88
|
+
lines = [l for l in f.read().decode("utf-8", "replace").split("\n") if l.strip()]
|
|
89
|
+
except OSError as e:
|
|
90
|
+
raise EngineError("audit_io", f"cannot read audit log: {e.strerror or e}") from None
|
|
91
|
+
try:
|
|
92
|
+
last = json.loads(lines[-1])
|
|
93
|
+
self.seq, self.head, self.len = int(last["seq"]), str(last["hash"]), size
|
|
94
|
+
except Exception:
|
|
95
|
+
raise EngineError("audit_broken", "the last audit record is unreadable") from None
|
|
96
|
+
|
|
97
|
+
def append(self, record: dict, ts: int) -> str:
|
|
98
|
+
self._resync()
|
|
99
|
+
rec = dict(record)
|
|
100
|
+
rec.pop("hash", None)
|
|
101
|
+
rec.update(seq=self.seq + 1, ts=ts, prev=self.head)
|
|
102
|
+
h = sha256_hex((self.head + canon(rec)).encode("utf-8"))
|
|
103
|
+
rec["hash"] = h
|
|
104
|
+
try:
|
|
105
|
+
with open(self.path, "a", encoding="utf-8", newline="\n") as f:
|
|
106
|
+
f.write(canon(rec) + "\n")
|
|
107
|
+
f.flush()
|
|
108
|
+
self.len = os.fstat(f.fileno()).st_size
|
|
109
|
+
except OSError as e:
|
|
110
|
+
raise EngineError("audit_io", f"cannot write audit log: {e.strerror or e}") from None
|
|
111
|
+
self.seq += 1
|
|
112
|
+
self.head = h
|
|
113
|
+
return h
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Operations shared by every backend. Each returns the engine's JSON result
|
|
2
|
+
as a dict; a result with ok == False raises SmartDelegateError."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import EngineError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# One error type for the engine and the clients. A deny is not an error: it
|
|
11
|
+
# is a normal result with effect == "deny".
|
|
12
|
+
SmartDelegateError = EngineError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Backend:
|
|
16
|
+
def call(self, op: str, payload: dict) -> dict: # pragma: no cover - overridden
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
def _ok(self, op: str, payload: dict) -> dict:
|
|
20
|
+
result = self.call(op, payload)
|
|
21
|
+
if not result.get("ok"):
|
|
22
|
+
err = result.get("error") or {}
|
|
23
|
+
raise SmartDelegateError(err.get("code", "error"), err.get("message", "unknown error"))
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def _request(token, action, resource, id, attrs, context, extra) -> dict:
|
|
28
|
+
res: dict[str, Any] = {"path": resource, "id": id if id is not None else "any"}
|
|
29
|
+
if attrs:
|
|
30
|
+
res["attrs"] = attrs
|
|
31
|
+
body: dict[str, Any] = {"token": token, "action": action, "resource": res}
|
|
32
|
+
if context:
|
|
33
|
+
body["context"] = context
|
|
34
|
+
body.update({k: v for k, v in extra.items() if v is not None})
|
|
35
|
+
return body
|
|
36
|
+
|
|
37
|
+
def authorize(self, token: str, action: str, resource: str, id: str | None = None, *, attrs: dict | None = None,
|
|
38
|
+
context: dict | None = None, fields: list[str] | None = None, dry_run: bool | None = None) -> dict:
|
|
39
|
+
return self._ok("authorize", self._request(token, action, resource, id, attrs, context, {"fields": fields, "dry_run": dry_run}))
|
|
40
|
+
|
|
41
|
+
def filter(self, token: str, action: str, resource: str, data: Any, id: str | None = None, *, attrs: dict | None = None,
|
|
42
|
+
context: dict | None = None, strict: bool = False) -> dict:
|
|
43
|
+
body = self._request(token, action, resource, id, attrs, context, {"strict": strict or None})
|
|
44
|
+
body["data"] = data
|
|
45
|
+
return self._ok("filter", body)
|
|
46
|
+
|
|
47
|
+
def explain(self, token: str, action: str, resource: str, id: str | None = None, *, attrs: dict | None = None, context: dict | None = None) -> dict:
|
|
48
|
+
return self._ok("explain", self._request(token, action, resource, id, attrs, context, {}))
|
|
49
|
+
|
|
50
|
+
def apply_obligations(self, decision: dict, data: Any) -> dict:
|
|
51
|
+
"""Mask data that arrived after the decision (a tool result)."""
|
|
52
|
+
return self._ok("apply_obligations", {"obligations": decision["obligations"], "data": data, "decision_id": decision.get("decision_id")})
|
|
53
|
+
|
|
54
|
+
def issue_token(self, agent: str, cap: dict | None = None, *, user: str | None = None, id_token: str | None = None,
|
|
55
|
+
ttl: int | None = None, tenant: str | None = None, task: str | None = None, role: str | None = None,
|
|
56
|
+
groups: list | None = None) -> dict:
|
|
57
|
+
"""`role`: lend the agent a role the user holds; the token can never
|
|
58
|
+
carry more than the role allows, and `cap` may then be omitted.
|
|
59
|
+
`groups`: the user's live group membership, when the caller has it
|
|
60
|
+
from the identity provider."""
|
|
61
|
+
body = {"agent": agent, "cap": cap, "user": user, "id_token": id_token, "ttl": ttl, "tenant": tenant, "task": task,
|
|
62
|
+
"role": role, "groups": groups}
|
|
63
|
+
return self._ok("issue_token", {k: v for k, v in body.items() if v is not None})
|
|
64
|
+
|
|
65
|
+
def exchange_token(self, subject_token: str, actor: str, *, cap: dict | None = None, ttl: int | None = None) -> dict:
|
|
66
|
+
body = {"subject_token": subject_token, "actor": actor, "cap": cap, "ttl": ttl}
|
|
67
|
+
return self._ok("exchange_token", {k: v for k, v in body.items() if v is not None})
|
|
68
|
+
|
|
69
|
+
def inspect_token(self, token: str) -> dict:
|
|
70
|
+
return self._ok("inspect_token", {"token": token})
|
|
71
|
+
|
|
72
|
+
def revoke(self, token_id: str) -> dict:
|
|
73
|
+
return self._ok("revoke", {"token_id": token_id})
|
|
74
|
+
|
|
75
|
+
def revoke_user(self, user: str, by: str | None = None) -> dict:
|
|
76
|
+
"""End every token already issued for this human (and their agents'
|
|
77
|
+
sub-agent tokens). Use it when a person is offboarded or loses a role."""
|
|
78
|
+
return self._ok("revoke", {k: v for k, v in {"user": user, "by": by}.items() if v is not None})
|
|
79
|
+
|
|
80
|
+
def approve(self, approval_id: str, by: str) -> dict:
|
|
81
|
+
return self._ok("approve", {"approval_id": approval_id, "by": by})
|
|
82
|
+
|
|
83
|
+
def info(self) -> dict:
|
|
84
|
+
return self._ok("info", {})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Bridges between SmartDelegate and real IAM systems. See docs/BRIDGES.md.
|
|
2
|
+
|
|
3
|
+
Inbound (the agent's bound follows the human's):
|
|
4
|
+
|
|
5
|
+
claims verified ID/access-token claims -> the `groups` list for issue_token
|
|
6
|
+
directory a directory snapshot -> entities.json (atomic, guarded write)
|
|
7
|
+
scim, okta, entra, keycloak, google, aws_identity_center
|
|
8
|
+
read users, groups and memberships into a `directory.Snapshot`
|
|
9
|
+
ceiling what the human may do in the cloud -> a cap on a role's ceiling
|
|
10
|
+
|
|
11
|
+
Outbound (a cloud credential no wider than the SmartDelegate token):
|
|
12
|
+
|
|
13
|
+
aws STS AssumeRole kwargs with a generated session policy and tags
|
|
14
|
+
gcp Credential Access Boundary + STS token exchange (Cloud Storage only)
|
|
15
|
+
azure On-Behalf-Of request with the scopes the cap maps to
|
|
16
|
+
|
|
17
|
+
Everything here is standard library only. Network calls go through an
|
|
18
|
+
injectable `http.Transport`; functions for a cloud SDK return plain dicts and
|
|
19
|
+
never import the SDK.
|
|
20
|
+
|
|
21
|
+
STATUS: every adapter was written from the provider's public documentation
|
|
22
|
+
and, except for Keycloak, has been run only against fake servers in tests/.
|
|
23
|
+
The Keycloak adapter and claim rules are verified against a real server
|
|
24
|
+
(live/idp). Nothing here has been run against a real AWS, Azure, Google or
|
|
25
|
+
Okta service. Details that
|
|
26
|
+
could not be checked in the documentation are marked `# UNVERIFIED:` in the
|
|
27
|
+
code and listed in docs/BRIDGES.md.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BridgeError(Exception):
|
|
33
|
+
"""A bridge could not do what was asked. Bridges fail closed: an HTTP
|
|
34
|
+
error, a malformed or truncated response, or an unexpected shape raises
|
|
35
|
+
this instead of returning a partial answer. `code` is stable and short;
|
|
36
|
+
`message` never contains a credential."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, code: str, message: str, detail=None):
|
|
39
|
+
super().__init__(f"{code}: {message}")
|
|
40
|
+
self.code = code
|
|
41
|
+
self.message = message
|
|
42
|
+
#: optional structured data for the caller (never a secret)
|
|
43
|
+
self.detail = detail
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
from . import http # noqa: E402
|
|
47
|
+
from . import claims, cloudmap, directory, ceiling # noqa: E402
|
|
48
|
+
from . import scim, okta, entra, keycloak, google, sigv4, aws_identity_center # noqa: E402
|
|
49
|
+
from . import aws, gcp, azure # noqa: E402
|
|
50
|
+
from .claims import groups_from_claims # noqa: E402
|
|
51
|
+
from .directory import Group, Snapshot, User, diff, revoke_changed, sync, to_entities, users_to_revoke, write_entities # noqa: E402
|
|
52
|
+
from .http import Transport, urllib_transport # noqa: E402
|
|
53
|
+
|
|
54
|
+
__all__ = ["BridgeError", "Transport", "urllib_transport", "groups_from_claims", "Snapshot", "User", "Group",
|
|
55
|
+
"to_entities", "diff", "write_entities", "sync", "revoke_changed", "users_to_revoke", "http", "claims", "cloudmap", "directory", "ceiling",
|
|
56
|
+
"scim", "okta", "entra", "keycloak", "google", "sigv4", "aws_identity_center", "aws", "gcp", "azure"]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""AWS: down-scope a role session to a SmartDelegate capability, and read a
|
|
2
|
+
human's ceiling from the IAM policy simulator.
|
|
3
|
+
|
|
4
|
+
Nothing here imports boto3. `assume_role_request` returns the keyword
|
|
5
|
+
arguments for `sts_client.assume_role(**kwargs)`; `simulation_request` those
|
|
6
|
+
for `iam_client.simulate_principal_policy(**kwargs)`.
|
|
7
|
+
|
|
8
|
+
Written from:
|
|
9
|
+
https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
|
|
10
|
+
Policy: inline session policy, at most 2,048 characters; DurationSeconds 900..43200
|
|
11
|
+
(default 3600); RoleSessionName 2..64 characters of [\\w+=,.@-]; Tags: at most 50, key
|
|
12
|
+
<= 128, value <= 256; TransitiveTagKeys
|
|
13
|
+
https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html
|
|
14
|
+
the role's trust policy must allow sts:TagSession; session tags appear in CloudTrail
|
|
15
|
+
under requestParameters.principalTags; policy and tags share a packed size limit
|
|
16
|
+
https://docs.aws.amazon.com/IAM/latest/APIReference/API_SimulatePrincipalPolicy.html
|
|
17
|
+
PolicySourceArn, ActionNames, ResourceArns -> EvaluationResults[] {EvalActionName,
|
|
18
|
+
EvalResourceName, EvalDecision}, IsTruncated, Marker
|
|
19
|
+
Tested only with a fake STS client and hand-written simulator results. AWS was not contacted.
|
|
20
|
+
|
|
21
|
+
What a session policy does: the session's permissions are the intersection of the role's
|
|
22
|
+
identity policies and the session policy. It can only take away. So the credential is never
|
|
23
|
+
wider than BOTH the role (`role.bindings.aws.role_arn`) and the capability as mapped.
|
|
24
|
+
|
|
25
|
+
# UNVERIFIED: EvalDecision values. The page read shows "allowed" and "implicitDeny";
|
|
26
|
+
# "explicitDeny" is from memory. Only the exact string "allowed" counts as allowed here.
|
|
27
|
+
# UNVERIFIED: how the simulator treats `*` inside a ResourceArns entry. `simulation_request`
|
|
28
|
+
# sends each mapped resource template with {id} replaced by `*`; a rule counts as allowed only
|
|
29
|
+
# if that exact entry comes back "allowed".
|
|
30
|
+
# UNVERIFIED: the packed (binary) size limit of policy plus tags cannot be computed locally; STS
|
|
31
|
+
# can still refuse a request that is under 2,048 characters. `assume_role` then raises.
|
|
32
|
+
# UNVERIFIED: that `?` and `${...}` are the only characters besides `*` with a special meaning in
|
|
33
|
+
# an IAM Resource element. Ids containing them are not granted (reported as "unsafe_id").
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import re
|
|
39
|
+
import time
|
|
40
|
+
|
|
41
|
+
from .. import tokens
|
|
42
|
+
from . import BridgeError
|
|
43
|
+
from .cloudmap import allowed_pairs, as_cap, not_enforced, pairs_to_cap, rules, select
|
|
44
|
+
|
|
45
|
+
POLICY_MAX_CHARS = 2048
|
|
46
|
+
DURATION_MIN, DURATION_MAX = 900, 43200
|
|
47
|
+
TAG_KEYS = {"user": "sd_user", "agent": "sd_agent", "role": "sd_role", "task": "sd_task", "token_id": "sd_token_id"}
|
|
48
|
+
_ROLE_ARN = re.compile(r"arn:aws[a-z-]*:iam::\d{12}:role/[\w+=,.@/-]+")
|
|
49
|
+
_DENY_ALL = {"Effect": "Deny", "Action": "*", "Resource": "*"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _safe_id(i: str) -> bool:
|
|
53
|
+
return "?" not in i and "${" not in i and "*" not in i[:-1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _statements(cap, cloud_map: dict) -> tuple:
|
|
57
|
+
grants, skipped = select(cap, rules(cloud_map, "aws"))
|
|
58
|
+
merged: dict = {}
|
|
59
|
+
for g in grants:
|
|
60
|
+
allow = g.rule["allow"]
|
|
61
|
+
resources = []
|
|
62
|
+
for t in allow["Resource"]:
|
|
63
|
+
if "{id}" in t:
|
|
64
|
+
if not _safe_id(g.id):
|
|
65
|
+
skipped.append({"action": g.rule["action"], "resource": g.resource, "reason": "unsafe_id"})
|
|
66
|
+
resources = []
|
|
67
|
+
break
|
|
68
|
+
resources.append(t.replace("{id}", g.id))
|
|
69
|
+
elif g.whole:
|
|
70
|
+
resources.append(t)
|
|
71
|
+
else:
|
|
72
|
+
skipped.append({"action": g.rule["action"], "resource": g.resource, "reason": "cannot_narrow", "template": t})
|
|
73
|
+
if not resources:
|
|
74
|
+
continue
|
|
75
|
+
key = (tuple(sorted(set(allow["Action"]))), json.dumps(allow.get("Condition"), sort_keys=True))
|
|
76
|
+
merged.setdefault(key, set()).update(resources)
|
|
77
|
+
out = []
|
|
78
|
+
for (actions, cond), res in sorted(merged.items()):
|
|
79
|
+
st = {"Effect": "Allow", "Action": list(actions), "Resource": sorted(res)}
|
|
80
|
+
if cond != "null":
|
|
81
|
+
st["Condition"] = json.loads(cond)
|
|
82
|
+
out.append(st)
|
|
83
|
+
return out, skipped
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def session_policy(cap, cloud_map: dict) -> dict:
|
|
87
|
+
"""The IAM session policy for a capability.
|
|
88
|
+
|
|
89
|
+
Guarantees: it has Allow statements only for (action, resource) pairs of
|
|
90
|
+
the capability that the map's `aws` rules cover, with every resource
|
|
91
|
+
narrowed to the capability; anything unmapped is absent (default deny);
|
|
92
|
+
the same inputs give the same policy. When nothing maps, the policy is a
|
|
93
|
+
single explicit Deny of everything - an empty policy would not be valid
|
|
94
|
+
and NO policy would leave the session with the role's full permissions.
|
|
95
|
+
Raises BridgeError("policy_too_large") when the compact JSON exceeds
|
|
96
|
+
2,048 characters, the documented limit for a session policy.
|
|
97
|
+
|
|
98
|
+
Not enforced by this policy: deny_labels, deny_fields and budget of the
|
|
99
|
+
capability (see `session_policy_report`)."""
|
|
100
|
+
statements, _ = _statements(cap, cloud_map)
|
|
101
|
+
policy = {"Version": "2012-10-17", "Statement": statements or [dict(_DENY_ALL)]}
|
|
102
|
+
size = len(policy_json(policy))
|
|
103
|
+
if size > POLICY_MAX_CHARS:
|
|
104
|
+
raise BridgeError("policy_too_large", f"the session policy is {size} characters; AWS allows {POLICY_MAX_CHARS}. "
|
|
105
|
+
"Narrow the capability or shorten the map", {"size": size})
|
|
106
|
+
return policy
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def policy_json(policy: dict) -> str:
|
|
110
|
+
"""The compact JSON text that is sent as `Policy` (and counted against the limit)."""
|
|
111
|
+
return json.dumps(policy, separators=(",", ":"), sort_keys=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def session_policy_report(cap, cloud_map: dict) -> dict:
|
|
115
|
+
"""{"policy", "size", "skipped", "not_enforced"}: the policy plus what the
|
|
116
|
+
capability has that the policy does not express, so that nothing is
|
|
117
|
+
dropped silently."""
|
|
118
|
+
policy = session_policy(cap, cloud_map)
|
|
119
|
+
return {"policy": policy, "size": len(policy_json(policy)), "skipped": _statements(cap, cloud_map)[1],
|
|
120
|
+
"not_enforced": not_enforced(cap)}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _principal(v) -> dict:
|
|
124
|
+
"""user / agent / role / task / token_id / exp / cap from a models.Grant, an
|
|
125
|
+
issue_token result, an inspect_token result, or a token's claims."""
|
|
126
|
+
if hasattr(v, "token_id") and hasattr(v, "chain"): # models.Grant
|
|
127
|
+
p = {"user": v.user, "chain": list(v.chain), "role": v.role, "task": getattr(v, "task", None), "token_id": v.token_id, "exp": v.expires_at,
|
|
128
|
+
"cap": v.capability.to_json()}
|
|
129
|
+
elif isinstance(v, dict) and isinstance(v.get("claims"), dict):
|
|
130
|
+
return _principal(v["claims"])
|
|
131
|
+
elif isinstance(v, dict) and "act" in v and "sub" in v:
|
|
132
|
+
p = {"user": tokens.user_of(v), "chain": tokens.chain_of(v), "role": v.get("role"), "task": v.get("task"),
|
|
133
|
+
"token_id": v.get("jti"), "exp": v.get("exp"), "cap": v.get("cap")}
|
|
134
|
+
elif isinstance(v, dict) and "token_id" in v:
|
|
135
|
+
p = {"user": v.get("user"), "chain": list(v.get("chain") or []), "role": v.get("role"), "task": v.get("task"),
|
|
136
|
+
"token_id": v.get("token_id"), "exp": v.get("expires_at"), "cap": v.get("cap")}
|
|
137
|
+
else:
|
|
138
|
+
raise BridgeError("invalid_input", "expected a Grant, an issue_token result, or token claims")
|
|
139
|
+
if not (isinstance(p["user"], str) and p["user"] and p["chain"] and isinstance(p["token_id"], str) and p["token_id"]
|
|
140
|
+
and isinstance(p["exp"], int) and not isinstance(p["exp"], bool)):
|
|
141
|
+
raise BridgeError("invalid_input", "the grant or claims lack user, agent chain, token id or expiry")
|
|
142
|
+
return p
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _clean(s: str, extra: str, limit: int) -> str:
|
|
146
|
+
return re.sub(rf"[^A-Za-z0-9_{re.escape(extra)}]", "-", s)[:limit]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def duration_for(expires_at: int, now: int, max_duration: int = 3600) -> tuple:
|
|
150
|
+
"""(DurationSeconds, seconds by which the credential outlives the token).
|
|
151
|
+
DurationSeconds = min(seconds the token has left, max_duration), raised to
|
|
152
|
+
AWS's minimum of 900. When the token has less than 900 seconds left the
|
|
153
|
+
credential therefore outlives it by the second number; the caller decides
|
|
154
|
+
whether that is acceptable. An expired token raises."""
|
|
155
|
+
left = expires_at - now
|
|
156
|
+
if left <= 0:
|
|
157
|
+
raise BridgeError("token_expired", "the capability token has expired; no cloud credential for it")
|
|
158
|
+
seconds = max(DURATION_MIN, min(left, max_duration, DURATION_MAX))
|
|
159
|
+
return seconds, max(0, seconds - left)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def assume_role_request(grant_or_claims, cap, cloud_map: dict, role_arn: str, *, max_duration: int = 3600, now: int | None = None) -> dict:
|
|
163
|
+
"""Keyword arguments for `sts.assume_role`, bounded by a capability token.
|
|
164
|
+
|
|
165
|
+
grant_or_claims a `models.Grant`, an issue_token / inspect_token result, or verified token claims
|
|
166
|
+
cap the capability to down-scope to; None uses the token's own
|
|
167
|
+
role_arn normally `role.bindings["aws"]["role_arn"]` from roles.json
|
|
168
|
+
|
|
169
|
+
Guarantees: `Policy` is `session_policy(cap, cloud_map)` (so the session
|
|
170
|
+
is no wider than the capability as mapped, and no wider than the role);
|
|
171
|
+
`RoleSessionName` is built from the agent and the token id and contains
|
|
172
|
+
only [A-Za-z0-9_+=,.@-], 2..64 characters; `DurationSeconds` is
|
|
173
|
+
`duration_for(...)`; `Tags` carry user, agent, role, task and token id
|
|
174
|
+
(keys sd_user, sd_agent, sd_role, sd_task, sd_token_id; absent values are
|
|
175
|
+
left out) and all are transitive, so CloudTrail shows who the session
|
|
176
|
+
acted for. The role's trust policy must allow sts:TagSession. No secret
|
|
177
|
+
is in the result."""
|
|
178
|
+
if not isinstance(role_arn, str) or not _ROLE_ARN.fullmatch(role_arn):
|
|
179
|
+
raise BridgeError("invalid_input", "role_arn is not an IAM role ARN")
|
|
180
|
+
p = _principal(grant_or_claims)
|
|
181
|
+
use = cap if cap is not None else p["cap"]
|
|
182
|
+
if use is None:
|
|
183
|
+
raise BridgeError("invalid_input", "no capability: pass cap or a grant that carries one")
|
|
184
|
+
if cap is not None and p["cap"] is not None:
|
|
185
|
+
try: # an explicit cap must not exceed the token it is said to belong to
|
|
186
|
+
as_cap(cap).check_narrower_than(as_cap(p["cap"]))
|
|
187
|
+
except Exception as e:
|
|
188
|
+
raise BridgeError("widening", f"the capability asked for is wider than the token's: {getattr(e, 'message', e)}") from None
|
|
189
|
+
policy = session_policy(use, cloud_map)
|
|
190
|
+
seconds, _ = duration_for(p["exp"], int(time.time()) if now is None else now, max_duration)
|
|
191
|
+
agent = p["chain"][-1]
|
|
192
|
+
name = _clean(f"{agent}-{p['token_id']}", "+=,.@-", 64)
|
|
193
|
+
values = {"user": p["user"], "agent": agent, "role": p["role"], "task": p["task"], "token_id": p["token_id"]}
|
|
194
|
+
tags = [{"Key": TAG_KEYS[k], "Value": _clean(str(v), " .:/=+@-", 256)} for k, v in values.items() if v]
|
|
195
|
+
return {"RoleArn": role_arn, "RoleSessionName": name if len(name) >= 2 else name + "-x", "Policy": policy_json(policy),
|
|
196
|
+
"DurationSeconds": seconds, "Tags": tags, "TransitiveTagKeys": [t["Key"] for t in tags]}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def assume_role(request: dict, sts_client) -> dict:
|
|
200
|
+
"""Call `sts_client.assume_role(**request)` on an injected boto3-like
|
|
201
|
+
client and return the `Credentials` of the answer (AccessKeyId,
|
|
202
|
+
SecretAccessKey, SessionToken, Expiration). Any failure raises
|
|
203
|
+
BridgeError("aws_error") naming the exception type only; the caller never
|
|
204
|
+
gets a wider credential as a fallback."""
|
|
205
|
+
if "Policy" not in request:
|
|
206
|
+
raise BridgeError("invalid_input", "refusing to assume a role without a session policy")
|
|
207
|
+
try:
|
|
208
|
+
answer = sts_client.assume_role(**request)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
raise BridgeError("aws_error", f"sts.assume_role failed: {type(e).__name__}") from None
|
|
211
|
+
creds = answer.get("Credentials") if isinstance(answer, dict) else None
|
|
212
|
+
if not isinstance(creds, dict) or not all(creds.get(k) for k in ("AccessKeyId", "SecretAccessKey", "SessionToken")):
|
|
213
|
+
raise BridgeError("malformed_response", "sts.assume_role returned no credentials")
|
|
214
|
+
return creds
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---- ceiling -------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def _concrete(rule: dict) -> tuple | None:
|
|
220
|
+
"""(actions, resources) to simulate for a rule, or None when the rule
|
|
221
|
+
names an action with a wildcard (which cannot be shown fully allowed)."""
|
|
222
|
+
actions = rule["allow"]["Action"]
|
|
223
|
+
if any("*" in a or "?" in a for a in actions):
|
|
224
|
+
return None
|
|
225
|
+
return sorted(set(actions)), sorted({r.replace("{id}", "*") for r in rule["allow"]["Resource"]})
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def simulation_request(cloud_map: dict, principal_arn: str) -> dict:
|
|
229
|
+
"""Keyword arguments for `iam.simulate_principal_policy` that ask, for
|
|
230
|
+
the human's IAM principal, about every action and resource the map's
|
|
231
|
+
`aws` rules name ({id} replaced by `*`). Rules with a wildcard action are
|
|
232
|
+
left out and can never enter the ceiling. Page through the results
|
|
233
|
+
(IsTruncated / Marker) and give all of them to `ceiling_from_simulation`."""
|
|
234
|
+
if not isinstance(principal_arn, str) or not principal_arn.startswith("arn:"):
|
|
235
|
+
raise BridgeError("invalid_input", "principal_arn must be the ARN of the human's IAM user or role")
|
|
236
|
+
actions, resources = set(), set()
|
|
237
|
+
for r in rules(cloud_map, "aws"):
|
|
238
|
+
c = _concrete(r)
|
|
239
|
+
if c:
|
|
240
|
+
actions.update(c[0])
|
|
241
|
+
resources.update(c[1])
|
|
242
|
+
if not actions:
|
|
243
|
+
raise BridgeError("nothing_to_simulate", "the map has no aws rule with concrete actions")
|
|
244
|
+
return {"PolicySourceArn": principal_arn, "ActionNames": sorted(actions), "ResourceArns": sorted(resources)}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _results(results) -> list:
|
|
248
|
+
if isinstance(results, dict):
|
|
249
|
+
results = [results]
|
|
250
|
+
if not isinstance(results, list):
|
|
251
|
+
raise BridgeError("invalid_input", "simulation results must be a response, a list of responses, or a list of EvaluationResults")
|
|
252
|
+
out = []
|
|
253
|
+
for n, r in enumerate(results):
|
|
254
|
+
if isinstance(r, dict) and "EvaluationResults" in r:
|
|
255
|
+
if r.get("IsTruncated") and n == len(results) - 1:
|
|
256
|
+
raise BridgeError("truncated_pagination", "the simulator results are truncated (IsTruncated); fetch every page first")
|
|
257
|
+
if not isinstance(r["EvaluationResults"], list):
|
|
258
|
+
raise BridgeError("malformed_response", "EvaluationResults is not a list")
|
|
259
|
+
out.extend(r["EvaluationResults"])
|
|
260
|
+
else:
|
|
261
|
+
out.append(r)
|
|
262
|
+
for r in out:
|
|
263
|
+
if not isinstance(r, dict) or not all(isinstance(r.get(k), str) for k in ("EvalActionName", "EvalResourceName", "EvalDecision")):
|
|
264
|
+
raise BridgeError("malformed_response", "an evaluation result lacks EvalActionName, EvalResourceName or EvalDecision")
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def ceiling_detail(results, cloud_map: dict) -> dict:
|
|
269
|
+
"""`cloudmap.pairs_to_cap` of the pairs the simulator shows fully allowed:
|
|
270
|
+
{"cap", "pairs", "dropped"}."""
|
|
271
|
+
decided: dict = {}
|
|
272
|
+
for r in _results(results):
|
|
273
|
+
key = (r["EvalActionName"], r["EvalResourceName"])
|
|
274
|
+
decided[key] = decided.get(key, True) and r["EvalDecision"] == "allowed"
|
|
275
|
+
rule_list = rules(cloud_map, "aws")
|
|
276
|
+
ok = []
|
|
277
|
+
for rule in rule_list:
|
|
278
|
+
c = _concrete(rule)
|
|
279
|
+
ok.append(bool(c) and all(decided.get((a, res)) is True for a in c[0] for res in c[1]))
|
|
280
|
+
return pairs_to_cap(allowed_pairs(rule_list, ok))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def ceiling_from_simulation(results, cloud_map: dict) -> dict:
|
|
284
|
+
"""What the simulated principal may do, as a capability {"actions",
|
|
285
|
+
"resources"} to cap a role with (`ceiling.cap_role`).
|
|
286
|
+
|
|
287
|
+
Guarantee: a SmartDelegate (action, resource) pair is in the result only
|
|
288
|
+
if every action and resource the map gives for it - in every rule that
|
|
289
|
+
overlaps the pair - came back with EvalDecision "allowed". A missing
|
|
290
|
+
result, any other decision, or a truncated result set keeps the pair out.
|
|
291
|
+
Since a capability is a product, pairs that do not fit are dropped, never
|
|
292
|
+
rounded up; `ceiling_detail` lists them."""
|
|
293
|
+
return ceiling_detail(results, cloud_map)["cap"]
|