grantry 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.
grantry/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """grantry: a local credential broker for humans and AI agents."""
2
+
3
+ __version__ = "0.1.0"
grantry/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable `python -m grantry`, which the MCP config points at so it works
2
+ regardless of PATH."""
3
+
4
+ from grantry.cli import main
5
+
6
+ raise SystemExit(main())
grantry/admin.py ADDED
@@ -0,0 +1,125 @@
1
+ """Org-wide assignment crawl: who has which permission set in which account.
2
+
3
+ This is admin-only by construction. It calls the sso-admin, organizations, and
4
+ identitystore APIs, which require signed credentials from an account that holds
5
+ those permissions (typically the management or a delegated-admin account).
6
+ grantry mints those credentials through the normal policy path, so a caller who
7
+ cannot assume such a role simply gets nothing; AWS is the gatekeeper.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable, Iterator
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ ClientFactory = Callable[[str], Any]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Assignment:
21
+ principal_type: str
22
+ principal_id: str
23
+ principal_name: str
24
+ permission_set_name: str
25
+ account_id: str
26
+ account_name: str
27
+
28
+
29
+ def _paginate(op: Callable[..., dict[str, Any]], key: str, **kwargs: Any) -> Iterator[Any]:
30
+ token: str | None = None
31
+ while True:
32
+ call = dict(kwargs)
33
+ if token:
34
+ call["NextToken"] = token
35
+ resp = op(**call)
36
+ yield from resp.get(key, [])
37
+ token = resp.get("NextToken")
38
+ if not token:
39
+ return
40
+
41
+
42
+ def _principal_name(
43
+ idstore: Any,
44
+ identity_store_id: str,
45
+ ptype: str,
46
+ pid: str,
47
+ cache: dict[tuple[str, str], str],
48
+ ) -> str:
49
+ # A group or user appears on many assignments; resolve each unique principal
50
+ # ONCE and cache it. At 10k+ assignments this turns tens of thousands of
51
+ # describe calls into one per distinct principal, which is the difference
52
+ # between a crawl that finishes and one that gets throttled.
53
+ key = (ptype, pid)
54
+ if key in cache:
55
+ return cache[key]
56
+ try:
57
+ if ptype == "GROUP":
58
+ resp = idstore.describe_group(IdentityStoreId=identity_store_id, GroupId=pid)
59
+ name = str(resp.get("DisplayName", pid))
60
+ else:
61
+ resp = idstore.describe_user(IdentityStoreId=identity_store_id, UserId=pid)
62
+ name = str(resp.get("UserName", pid))
63
+ except Exception:
64
+ name = pid
65
+ cache[key] = name
66
+ return name
67
+
68
+
69
+ def crawl_assignments(
70
+ make_client: ClientFactory,
71
+ on_progress: Callable[[int, int], None] | None = None,
72
+ ) -> list[Assignment]:
73
+ sso = make_client("sso-admin")
74
+ idstore = make_client("identitystore")
75
+ orgs = make_client("organizations")
76
+
77
+ instances = sso.list_instances().get("Instances", [])
78
+ if not instances:
79
+ return []
80
+ instance_arn = instances[0]["InstanceArn"]
81
+ identity_store_id = instances[0]["IdentityStoreId"]
82
+
83
+ accounts: dict[str, str] = {}
84
+ for acct in _paginate(orgs.list_accounts, "Accounts"):
85
+ accounts[acct["Id"]] = acct.get("Name", acct["Id"])
86
+
87
+ ps_names: dict[str, str] = {}
88
+ for ps_arn in _paginate(sso.list_permission_sets, "PermissionSets", InstanceArn=instance_arn):
89
+ d = sso.describe_permission_set(InstanceArn=instance_arn, PermissionSetArn=ps_arn)
90
+ ps_names[ps_arn] = d["PermissionSet"]["Name"]
91
+
92
+ out: list[Assignment] = []
93
+ name_cache: dict[tuple[str, str], str] = {}
94
+ total_accounts = len(accounts)
95
+ for done, (acct_id, acct_name) in enumerate(accounts.items(), start=1):
96
+ for ps_arn in _paginate(
97
+ sso.list_permission_sets_provisioned_to_account,
98
+ "PermissionSets",
99
+ InstanceArn=instance_arn,
100
+ AccountId=acct_id,
101
+ ):
102
+ for a in _paginate(
103
+ sso.list_account_assignments,
104
+ "AccountAssignments",
105
+ InstanceArn=instance_arn,
106
+ AccountId=acct_id,
107
+ PermissionSetArn=ps_arn,
108
+ ):
109
+ ptype = a["PrincipalType"]
110
+ pid = a["PrincipalId"]
111
+ out.append(
112
+ Assignment(
113
+ principal_type=ptype,
114
+ principal_id=pid,
115
+ principal_name=_principal_name(
116
+ idstore, identity_store_id, ptype, pid, name_cache
117
+ ),
118
+ permission_set_name=ps_names.get(ps_arn, ps_arn),
119
+ account_id=acct_id,
120
+ account_name=acct_name,
121
+ )
122
+ )
123
+ if on_progress:
124
+ on_progress(done, total_accounts)
125
+ return out