azure-pim-cli 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 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,189 @@
1
+ """Prime an acrs=c1 (step-up MFA) Graph token via portal.
2
+
3
+ The Graph PIM activation POST refuses tokens lacking the `acrs=c1` claim
4
+ (`RoleAssignmentRequestAcrsValidationFailed`). Portal handles this by calling
5
+ MSAL with a claims challenge; if the user has a cached MFA session
6
+ (`kmsi`/`dvc_cmp`), portal upgrades silently and retries. We can't do that
7
+ step-up in raw Python (no refresh token), but we CAN drive portal to do it for
8
+ us via Playwright over CDP.
9
+
10
+ Flow:
11
+ 1. Attach to authenticated Chrome (debug port 9222).
12
+ 2. Navigate to PIM Activation blade.
13
+ 3. Click Activate on the first eligible row.
14
+ 4. Fill reason, submit.
15
+ 5. Sniff the resulting Graph POST — its Authorization header contains a fresh
16
+ token with `acrs=c1`. Return that token.
17
+
18
+ If cached MFA has lapsed, portal shows an MFA modal in the visible browser
19
+ window — user completes it, script picks up the token afterwards. No signal
20
+ file / TEMP handshake needed.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import json
27
+ import sys
28
+ import threading
29
+ import time
30
+
31
+ from playwright.sync_api import sync_playwright
32
+
33
+ ACTIVATION_URL = (
34
+ "https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/aadgroup"
35
+ )
36
+
37
+
38
+ def _has_c1(token: str) -> bool:
39
+ try:
40
+ payload = token.split(".")[1]
41
+ payload += "=" * (-len(payload) % 4)
42
+ claims = json.loads(base64.urlsafe_b64decode(payload))
43
+ except Exception:
44
+ return False
45
+ acrs = claims.get("acrs") or []
46
+ if isinstance(acrs, str):
47
+ acrs = [acrs]
48
+ return "c1" in [str(a).lower() for a in acrs]
49
+
50
+
51
+ def prime_acrs(cdp_endpoint: str, justification: str = "acrs prime", timeout: int = 180) -> str:
52
+ """Drive portal to fire a Graph POST bearing an acrs=c1 token; return that token.
53
+
54
+ Raises RuntimeError on timeout / capture failure.
55
+ """
56
+ token_holder: dict[str, str] = {}
57
+ token_event = threading.Event()
58
+
59
+ def on_req(request):
60
+ if token_event.is_set():
61
+ return
62
+ try:
63
+ url = request.url
64
+ method = request.method
65
+ except Exception:
66
+ return
67
+ if "graph.microsoft.com" not in url:
68
+ return
69
+ if method != "POST":
70
+ return
71
+ if "assignmentScheduleRequests" not in url:
72
+ return
73
+ auth = request.headers.get("authorization", "")
74
+ if not auth.lower().startswith("bearer "):
75
+ return
76
+ candidate = auth.split(" ", 1)[1]
77
+ if not _has_c1(candidate):
78
+ return
79
+ token_holder["token"] = candidate
80
+ token_event.set()
81
+
82
+ with sync_playwright() as pw:
83
+ browser = pw.chromium.connect_over_cdp(cdp_endpoint)
84
+ ctx = browser.contexts[0]
85
+
86
+ # Register on ALL current + future pages (portal iframes fire XHRs)
87
+ for existing in ctx.pages:
88
+ existing.on("request", on_req)
89
+ ctx.on("page", lambda p: p.on("request", on_req))
90
+
91
+ # Prefer a portal tab; else new tab
92
+ page = None
93
+ for pg in ctx.pages:
94
+ if "portal.azure.com" in (pg.url or ""):
95
+ page = pg
96
+ break
97
+ if page is None:
98
+ page = ctx.new_page()
99
+ page.on("request", on_req)
100
+
101
+ print("[acrs] navigating portal tab to activation blade...", file=sys.stderr)
102
+ try:
103
+ page.goto(ACTIVATION_URL, wait_until="commit", timeout=60_000)
104
+ except Exception as e:
105
+ print(f"[acrs] nav warning: {e}", file=sys.stderr)
106
+
107
+ # Wait for eligibility grid to render
108
+ try:
109
+ page.wait_for_selector('button:has-text("Activeren")', timeout=30_000)
110
+ except Exception as e:
111
+ raise RuntimeError(f"activation blade did not load: {e}")
112
+
113
+ # Click first grid-row Activate (skip sidebar tab button by scoping to gridcell)
114
+ print("[acrs] clicking first Activate on eligibility row...", file=sys.stderr)
115
+ try:
116
+ page.locator('[role="gridcell"] button:has-text("Activeren")').first.click(
117
+ timeout=10_000
118
+ )
119
+ except Exception:
120
+ # Fallback: any visible Activeren, but skip the sidebar tab (has "in-/uitschakelen")
121
+ btns = page.locator(
122
+ 'button:has-text("Activeren"):not(:has-text("in-/uitschakelen"))'
123
+ ).all()
124
+ if not btns:
125
+ raise RuntimeError("could not find any Activate button on grid.")
126
+ btns[0].click()
127
+
128
+ # Wait for confirmation panel with reason textbox
129
+ try:
130
+ page.wait_for_selector(
131
+ 'textbox[aria-label*="Reden"], textarea[aria-label*="Reden"], [role="textbox"][aria-label*="Reden"]',
132
+ timeout=15_000,
133
+ )
134
+ except Exception:
135
+ # Portal panel loads a bit slower sometimes
136
+ time.sleep(2)
137
+
138
+ # Fill reason
139
+ print("[acrs] filling reason + submitting...", file=sys.stderr)
140
+ try:
141
+ page.get_by_role("textbox", name="Reden").first.fill(justification)
142
+ except Exception:
143
+ try:
144
+ page.locator('[role="textbox"][aria-label*="Reden"]').first.fill(justification)
145
+ except Exception as e:
146
+ print(
147
+ f"[acrs] warn: could not fill reason ({e}); portal may accept empty for silent step-up.",
148
+ file=sys.stderr,
149
+ )
150
+
151
+ # Click the panel Activate (bottom-most button in the aside/complementary panel)
152
+ try:
153
+ # Aside panel Activate — usually the last visible Activate button
154
+ panel_buttons = page.get_by_role("button", name="Activeren").all()
155
+ # Pick a panel button (not the row buttons). The confirmation panel
156
+ # button follows all row buttons; taking the last one is reliable.
157
+ panel_buttons[-1].click()
158
+ except Exception as e:
159
+ raise RuntimeError(f"could not click confirmation Activate button: {e}")
160
+
161
+ print(f"[acrs] waiting for acrs=c1 POST (up to {timeout}s)...", file=sys.stderr)
162
+ print(
163
+ "[acrs] if MFA prompt appears in the browser, complete it — script will pick up the token.",
164
+ file=sys.stderr,
165
+ )
166
+
167
+ deadline = time.time() + timeout
168
+ while time.time() < deadline:
169
+ if token_event.wait(timeout=2):
170
+ break
171
+
172
+ if not token_event.is_set():
173
+ raise RuntimeError("no acrs=c1 token captured after portal prime (timeout).")
174
+
175
+ token = token_holder["token"]
176
+ print("[acrs] captured acrs=c1 token.", file=sys.stderr)
177
+ return token
178
+
179
+
180
+ if __name__ == "__main__":
181
+ import argparse
182
+
183
+ ap = argparse.ArgumentParser()
184
+ ap.add_argument("--cdp-endpoint", default="http://localhost:9222")
185
+ ap.add_argument("--justification", default="acrs prime")
186
+ ap.add_argument("--timeout", type=int, default=180)
187
+ a = ap.parse_args()
188
+ tok = prime_acrs(a.cdp_endpoint, a.justification, a.timeout)
189
+ print(tok)
azure_pim_cli/cache.py ADDED
@@ -0,0 +1,67 @@
1
+ """Cache load/save + NEW-diff for eligible PIM group assignments.
2
+
3
+ JSON schema matches Activate-PimGroup.ps1 so an existing PS cache seeds the diff.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+
13
+ from dateutil import parser as dateparser
14
+
15
+ CACHE_MAX_HOURS = 24
16
+
17
+
18
+ def cache_dir() -> Path:
19
+ if os.environ.get("LOCALAPPDATA"):
20
+ d = Path(os.environ["LOCALAPPDATA"]) / "pim_activate"
21
+ else:
22
+ d = Path.home() / ".pim_activate"
23
+ d.mkdir(parents=True, exist_ok=True)
24
+ return d
25
+
26
+
27
+ def cache_file() -> Path:
28
+ return cache_dir() / "eligible_cache.json"
29
+
30
+
31
+ def load() -> dict | None:
32
+ f = cache_file()
33
+ if not f.exists():
34
+ return None
35
+ try:
36
+ return json.loads(f.read_text(encoding="utf-8"))
37
+ except Exception:
38
+ return None
39
+
40
+
41
+ def is_fresh(cache: dict, principal_id: str) -> bool:
42
+ if not cache or cache.get("principalId") != principal_id:
43
+ return False
44
+ fetched = cache.get("fetchedAt")
45
+ if not fetched:
46
+ return False
47
+ try:
48
+ age = datetime.now(UTC) - dateparser.isoparse(fetched)
49
+ except Exception:
50
+ return False
51
+ return age.total_seconds() < CACHE_MAX_HOURS * 3600
52
+
53
+
54
+ def save(principal_id: str, eligible: list[dict]) -> None:
55
+ payload = {
56
+ "fetchedAt": datetime.now(UTC).isoformat(),
57
+ "principalId": principal_id,
58
+ "eligible": eligible,
59
+ }
60
+ cache_file().write_text(json.dumps(payload, indent=2), encoding="utf-8")
61
+
62
+
63
+ def mark_new(current: list[dict], previous: list[dict] | None) -> list[dict]:
64
+ prev_ids = {e["groupId"] for e in (previous or [])}
65
+ for e in current:
66
+ e["isNew"] = bool(prev_ids) and e["groupId"] not in prev_ids
67
+ return current
@@ -0,0 +1,188 @@
1
+ """Launch Chrome with remote debugging port, using a copy of the user's real profile.
2
+
3
+ Why: Intune device compliance cookies live in the user's real Chrome profile. Playwright's
4
+ own persistent profile lacks them, so Conditional Access refuses portal sign-in with
5
+ "U kunt op dit moment geen toegang krijgen". Copying the real profile into a debug-
6
+ friendly directory sidesteps both Intune's remote-debugging-port block (which only fires
7
+ on the managed real profile) and the CA compliance check (cookies came along in the copy).
8
+
9
+ Flow:
10
+ 1. If debug port already responding -> reuse (fast path).
11
+ 2. Else kill running chrome.exe (debug port requires exclusive use).
12
+ 3. Copy real profile -> C:\\temp\\chrome_pim_profile (skip if target exists unless refresh).
13
+ 4. Launch chrome.exe --remote-debugging-port=<port> --user-data-dir=<copy> <start-url>.
14
+ 5. Poll /json/version until ready, return CDP endpoint URL.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ import urllib.error
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ DEFAULT_PORT = 9222
28
+ DEFAULT_COPY_PROFILE = Path(r"C:\temp\chrome_pim_profile")
29
+ DEFAULT_START_URL = (
30
+ "https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/aadgroup"
31
+ )
32
+
33
+
34
+ def _chrome_exe() -> str:
35
+ candidates = [
36
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
37
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
38
+ os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
39
+ ]
40
+ for p in candidates:
41
+ if os.path.isfile(p):
42
+ return p
43
+ raise RuntimeError(f"chrome.exe not found in any of: {candidates}")
44
+
45
+
46
+ def _default_source_profile() -> Path:
47
+ return Path(os.environ["LOCALAPPDATA"]) / "Google" / "Chrome" / "User Data"
48
+
49
+
50
+ def _port_alive(port: int) -> bool:
51
+ try:
52
+ with urllib.request.urlopen(f"http://localhost:{port}/json/version", timeout=1) as r:
53
+ return r.status == 200
54
+ except (urllib.error.URLError, ConnectionResetError, OSError):
55
+ return False
56
+
57
+
58
+ def _wait_ready(port: int, timeout: float = 30.0) -> bool:
59
+ deadline = time.time() + timeout
60
+ while time.time() < deadline:
61
+ if _port_alive(port):
62
+ return True
63
+ time.sleep(0.5)
64
+ return False
65
+
66
+
67
+ def _kill_chrome() -> None:
68
+ subprocess.run(["taskkill", "/F", "/IM", "chrome.exe"], capture_output=True)
69
+
70
+
71
+ def _copy_profile(src: Path, dst: Path) -> None:
72
+ dst.parent.mkdir(parents=True, exist_ok=True)
73
+ # robocopy /E all subdirs, /XJ skip junctions (avoids symlink loops in Chrome cache),
74
+ # /R:1 /W:1 minimise retries on locked files, /NFL /NDL /NJH /NJS quiet output.
75
+ subprocess.run(
76
+ [
77
+ "robocopy",
78
+ str(src),
79
+ str(dst),
80
+ "/E",
81
+ "/XJ",
82
+ "/R:1",
83
+ "/W:1",
84
+ "/NFL",
85
+ "/NDL",
86
+ "/NJH",
87
+ "/NJS",
88
+ "/NC",
89
+ "/NS",
90
+ "/NP",
91
+ ],
92
+ capture_output=True,
93
+ )
94
+
95
+
96
+ def launch_debug_chrome(
97
+ port: int = DEFAULT_PORT,
98
+ copy_profile: Path = DEFAULT_COPY_PROFILE,
99
+ source_profile: Path | None = None,
100
+ start_url: str = DEFAULT_START_URL,
101
+ force_profile_refresh: bool = False,
102
+ ) -> str:
103
+ """Ensure a Chrome with debug port is running against a profile that has Intune cookies.
104
+
105
+ Returns CDP endpoint URL (http://localhost:<port>).
106
+ """
107
+ if _port_alive(port) and not force_profile_refresh:
108
+ print(f"[chrome] debug port {port} already responding; reusing.", file=sys.stderr)
109
+ return f"http://localhost:{port}"
110
+ if _port_alive(port) and force_profile_refresh:
111
+ print(
112
+ "[chrome] --refresh-chrome-profile set; restarting Chrome to apply fresh profile.",
113
+ file=sys.stderr,
114
+ )
115
+
116
+ exe = _chrome_exe()
117
+ src = source_profile or _default_source_profile()
118
+
119
+ print("[chrome] killing chrome.exe (need exclusive debug port)...", file=sys.stderr)
120
+ _kill_chrome()
121
+ time.sleep(1)
122
+
123
+ need_copy = (
124
+ force_profile_refresh or not copy_profile.exists() or not any(copy_profile.iterdir())
125
+ )
126
+ if need_copy:
127
+ if not src.exists():
128
+ raise RuntimeError(f"source profile not found: {src}")
129
+ print(
130
+ f"[chrome] copying profile {src} -> {copy_profile} (may take 30-90s)...",
131
+ file=sys.stderr,
132
+ )
133
+ _copy_profile(src, copy_profile)
134
+ else:
135
+ print(
136
+ f"[chrome] reusing existing profile copy at {copy_profile} "
137
+ "(pass --refresh-chrome-profile to re-copy).",
138
+ file=sys.stderr,
139
+ )
140
+
141
+ args = [
142
+ exe,
143
+ f"--remote-debugging-port={port}",
144
+ f"--user-data-dir={copy_profile}",
145
+ "--no-first-run",
146
+ "--no-default-browser-check",
147
+ start_url,
148
+ ]
149
+ print(
150
+ f"[chrome] launching: {exe} --remote-debugging-port={port} --user-data-dir={copy_profile}",
151
+ file=sys.stderr,
152
+ )
153
+
154
+ # DETACHED_PROCESS = 0x00000008; keeps Chrome alive after Python exits.
155
+ creationflags = 0x00000008 if os.name == "nt" else 0
156
+ subprocess.Popen(args, creationflags=creationflags, close_fds=True)
157
+
158
+ print(f"[chrome] waiting for debug port {port}...", file=sys.stderr)
159
+ if not _wait_ready(port):
160
+ raise RuntimeError(
161
+ f"Chrome debug port {port} not responding after 30s. "
162
+ "Chrome may be blocked by policy or failed to launch."
163
+ )
164
+ print(f"[chrome] ready at http://localhost:{port}", file=sys.stderr)
165
+ print("[chrome] complete portal sign-in in the new Chrome window if prompted.", file=sys.stderr)
166
+ return f"http://localhost:{port}"
167
+
168
+
169
+ if __name__ == "__main__":
170
+ import argparse
171
+
172
+ p = argparse.ArgumentParser(description="Launch Chrome with debug port on a copied profile.")
173
+ p.add_argument("--port", type=int, default=DEFAULT_PORT)
174
+ p.add_argument("--copy-profile", default=str(DEFAULT_COPY_PROFILE))
175
+ p.add_argument("--source-profile", default=None)
176
+ p.add_argument(
177
+ "--refresh", action="store_true", help="Re-copy real profile even if target exists."
178
+ )
179
+ p.add_argument("--url", default=DEFAULT_START_URL)
180
+ a = p.parse_args()
181
+ endpoint = launch_debug_chrome(
182
+ port=a.port,
183
+ copy_profile=Path(a.copy_profile),
184
+ source_profile=Path(a.source_profile) if a.source_profile else None,
185
+ start_url=a.url,
186
+ force_profile_refresh=a.refresh,
187
+ )
188
+ print(endpoint)