power-admin 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.
- power_admin/__init__.py +3 -0
- power_admin/auth.py +161 -0
- power_admin/cli.py +208 -0
- power_admin/collectors.py +209 -0
- power_admin/config.py +72 -0
- power_admin/http.py +62 -0
- power_admin/storage.py +74 -0
- power_admin-0.1.0.dist-info/METADATA +159 -0
- power_admin-0.1.0.dist-info/RECORD +12 -0
- power_admin-0.1.0.dist-info/WHEEL +4 -0
- power_admin-0.1.0.dist-info/entry_points.txt +2 -0
- power_admin-0.1.0.dist-info/licenses/LICENSE +21 -0
power_admin/__init__.py
ADDED
power_admin/auth.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Token acquisition for the Power Platform API and Microsoft Graph.
|
|
2
|
+
|
|
3
|
+
Supported modes:
|
|
4
|
+
interactive browser sign-in as you (delegated). Default.
|
|
5
|
+
device-code sign in on any device with a code (delegated). Good for SSH or locked-down shells.
|
|
6
|
+
client-secret app-only service principal with a client secret.
|
|
7
|
+
certificate app-only service principal with a certificate (PEM private key + thumbprint).
|
|
8
|
+
token bring your own access tokens via POWER_ADMIN_PP_TOKEN / POWER_ADMIN_GRAPH_TOKEN.
|
|
9
|
+
|
|
10
|
+
Delegated modes cache tokens in ~/.power-admin, so you sign in once and later runs are silent
|
|
11
|
+
until the refresh token expires. Note the Power Platform inventory API only accepts
|
|
12
|
+
delegated (user) tokens; app-only modes get 403 there.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
import msal
|
|
22
|
+
|
|
23
|
+
from .config import GRAPH_SCOPE, PP_SCOPE, TOKEN_CACHE_FILE, Settings
|
|
24
|
+
|
|
25
|
+
DELEGATED_MODES = {"interactive", "device-code"}
|
|
26
|
+
|
|
27
|
+
# Microsoft's public client ID for Azure PowerShell (the one Connect-AzAccount uses). It is
|
|
28
|
+
# pre-authorized for the Power Platform API and Graph, so delegated sign-in works without
|
|
29
|
+
# registering your own Entra app. Override with --client-id if your tenant blocks it.
|
|
30
|
+
DEFAULT_PUBLIC_CLIENT_ID = "1950a258-227b-4e31-a9cf-717495945fc2"
|
|
31
|
+
APP_ONLY_MODES = {"client-secret", "certificate"}
|
|
32
|
+
|
|
33
|
+
_TOKEN_ENV = {PP_SCOPE: "POWER_ADMIN_PP_TOKEN", GRAPH_SCOPE: "POWER_ADMIN_GRAPH_TOKEN"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthError(RuntimeError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_cache() -> msal.SerializableTokenCache:
|
|
41
|
+
cache = msal.SerializableTokenCache()
|
|
42
|
+
if TOKEN_CACHE_FILE.exists():
|
|
43
|
+
cache.deserialize(TOKEN_CACHE_FILE.read_text(encoding="utf-8"))
|
|
44
|
+
return cache
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _save_cache(cache: msal.SerializableTokenCache) -> None:
|
|
48
|
+
if cache.has_state_changed:
|
|
49
|
+
TOKEN_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
TOKEN_CACHE_FILE.write_text(cache.serialize(), encoding="utf-8")
|
|
51
|
+
try:
|
|
52
|
+
os.chmod(TOKEN_CACHE_FILE, 0o600)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TokenProvider:
|
|
58
|
+
"""Returns bearer tokens per scope, signing in on first use."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, settings: Settings, echo: Callable[[str], None] = print):
|
|
61
|
+
self.settings = settings
|
|
62
|
+
self.echo = echo
|
|
63
|
+
self._cache = _load_cache()
|
|
64
|
+
self._app: msal.ClientApplication | None = None
|
|
65
|
+
self._memo: dict[str, str] = {}
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def mode(self) -> str:
|
|
69
|
+
return self.settings.auth_mode
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_delegated(self) -> bool:
|
|
73
|
+
return self.mode in DELEGATED_MODES or self.mode == "token"
|
|
74
|
+
|
|
75
|
+
def _require(self, *names: str) -> None:
|
|
76
|
+
missing = [n for n in names if not getattr(self.settings, n)]
|
|
77
|
+
if missing:
|
|
78
|
+
flags = ", ".join("--" + n.replace("_", "-") for n in missing)
|
|
79
|
+
raise AuthError(f"Auth mode {self.mode!r} needs {flags} (or the matching POWER_ADMIN_* env vars)")
|
|
80
|
+
|
|
81
|
+
def _client(self) -> msal.ClientApplication:
|
|
82
|
+
if self._app is not None:
|
|
83
|
+
return self._app
|
|
84
|
+
s = self.settings
|
|
85
|
+
if self.mode in DELEGATED_MODES:
|
|
86
|
+
self._app = msal.PublicClientApplication(
|
|
87
|
+
s.client_id or DEFAULT_PUBLIC_CLIENT_ID, authority=s.authority, token_cache=self._cache
|
|
88
|
+
)
|
|
89
|
+
elif self.mode == "client-secret":
|
|
90
|
+
self._require("tenant_id", "client_id", "client_secret")
|
|
91
|
+
self._app = msal.ConfidentialClientApplication(
|
|
92
|
+
s.client_id, authority=s.authority, client_credential=s.client_secret, token_cache=self._cache
|
|
93
|
+
)
|
|
94
|
+
elif self.mode == "certificate":
|
|
95
|
+
self._require("tenant_id", "client_id", "certificate_path", "certificate_thumbprint")
|
|
96
|
+
key = Path(s.certificate_path).read_text(encoding="utf-8")
|
|
97
|
+
self._app = msal.ConfidentialClientApplication(
|
|
98
|
+
s.client_id,
|
|
99
|
+
authority=s.authority,
|
|
100
|
+
client_credential={"private_key": key, "thumbprint": s.certificate_thumbprint},
|
|
101
|
+
token_cache=self._cache,
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
raise AuthError(f"Auth mode {self.mode!r} does not use MSAL")
|
|
105
|
+
return self._app
|
|
106
|
+
|
|
107
|
+
def get_token(self, scope: str) -> str:
|
|
108
|
+
if scope in self._memo:
|
|
109
|
+
return self._memo[scope]
|
|
110
|
+
token = self._acquire(scope)
|
|
111
|
+
self._memo[scope] = token
|
|
112
|
+
return token
|
|
113
|
+
|
|
114
|
+
def _acquire(self, scope: str) -> str:
|
|
115
|
+
if self.mode == "token":
|
|
116
|
+
env = _TOKEN_ENV.get(scope)
|
|
117
|
+
value = os.environ.get(env or "")
|
|
118
|
+
if not value:
|
|
119
|
+
raise AuthError(f"Auth mode 'token' needs the {env} environment variable for {scope}")
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
app = self._client()
|
|
123
|
+
scopes = [scope]
|
|
124
|
+
result = None
|
|
125
|
+
if self.mode in APP_ONLY_MODES:
|
|
126
|
+
result = app.acquire_token_for_client(scopes=scopes)
|
|
127
|
+
else:
|
|
128
|
+
accounts = app.get_accounts()
|
|
129
|
+
if accounts:
|
|
130
|
+
result = app.acquire_token_silent(scopes, account=accounts[0])
|
|
131
|
+
if not result:
|
|
132
|
+
result = self._sign_in(app, scopes)
|
|
133
|
+
_save_cache(self._cache)
|
|
134
|
+
|
|
135
|
+
if not result or "access_token" not in result:
|
|
136
|
+
detail = (result or {}).get("error_description") or (result or {}).get("error") or "no token returned"
|
|
137
|
+
raise AuthError(f"Could not get a token for {scope}: {detail}")
|
|
138
|
+
return result["access_token"]
|
|
139
|
+
|
|
140
|
+
def _sign_in(self, app: msal.PublicClientApplication, scopes: list[str]) -> dict:
|
|
141
|
+
if self.mode == "device-code":
|
|
142
|
+
flow = app.initiate_device_flow(scopes=scopes)
|
|
143
|
+
if "user_code" not in flow:
|
|
144
|
+
raise AuthError(f"Device code flow failed: {flow.get('error_description', flow)}")
|
|
145
|
+
self.echo(flow["message"])
|
|
146
|
+
return app.acquire_token_by_device_flow(flow)
|
|
147
|
+
self.echo("Opening a browser window to sign in...")
|
|
148
|
+
return app.acquire_token_interactive(scopes=scopes, prompt="select_account")
|
|
149
|
+
|
|
150
|
+
def signed_in_user(self) -> str | None:
|
|
151
|
+
if self.mode not in DELEGATED_MODES:
|
|
152
|
+
return None
|
|
153
|
+
accounts = self._client().get_accounts()
|
|
154
|
+
return accounts[0].get("username") if accounts else None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def logout() -> bool:
|
|
158
|
+
if TOKEN_CACHE_FILE.exists():
|
|
159
|
+
TOKEN_CACHE_FILE.unlink()
|
|
160
|
+
return True
|
|
161
|
+
return False
|
power_admin/cli.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""power-admin command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timedelta, timezone
|
|
6
|
+
from typing import Annotated, Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .auth import APP_ONLY_MODES, AuthError, TokenProvider, logout as clear_cache
|
|
12
|
+
from .collectors import COLLECTORS, PullContext
|
|
13
|
+
from .config import AUTH_MODES, CONFIG_FILE, GRAPH_SCOPE, PP_SCOPE, load_settings, save_settings
|
|
14
|
+
from .http import ApiClient, ApiError
|
|
15
|
+
from .storage import configure_s3, write_dataset, write_manifest
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
help="Pull Power Platform admin data (inventory, capacity, Copilot credits, users) to a folder or S3.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Shared auth options -------------------------------------------------------
|
|
24
|
+
TenantOpt = Annotated[Optional[str], typer.Option("--tenant-id", "-t", help="Entra tenant ID or domain.")]
|
|
25
|
+
ClientOpt = Annotated[Optional[str], typer.Option("--client-id", "-c", help="App registration (client) ID. Optional for interactive/device-code.")]
|
|
26
|
+
AuthOpt = Annotated[
|
|
27
|
+
Optional[str],
|
|
28
|
+
typer.Option("--auth", "-a", help=f"Auth mode: {', '.join(AUTH_MODES)}. Default: interactive."),
|
|
29
|
+
]
|
|
30
|
+
SecretOpt = Annotated[
|
|
31
|
+
Optional[str], typer.Option("--client-secret", help="Client secret (client-secret mode). Prefer POWER_ADMIN_CLIENT_SECRET.")
|
|
32
|
+
]
|
|
33
|
+
CertOpt = Annotated[Optional[str], typer.Option("--certificate-path", help="PEM private key (certificate mode).")]
|
|
34
|
+
ThumbOpt = Annotated[Optional[str], typer.Option("--certificate-thumbprint", help="Certificate thumbprint.")]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _settings(tenant_id, client_id, auth, client_secret=None, certificate_path=None, certificate_thumbprint=None, output=None):
|
|
38
|
+
try:
|
|
39
|
+
return load_settings(
|
|
40
|
+
tenant_id=tenant_id,
|
|
41
|
+
client_id=client_id,
|
|
42
|
+
auth_mode=auth,
|
|
43
|
+
client_secret=client_secret,
|
|
44
|
+
certificate_path=certificate_path,
|
|
45
|
+
certificate_thumbprint=certificate_thumbprint,
|
|
46
|
+
output=output,
|
|
47
|
+
)
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_date(value: Optional[str], default: date) -> date:
|
|
53
|
+
return date.fromisoformat(value) if value else default
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def version() -> None:
|
|
58
|
+
"""Print the version."""
|
|
59
|
+
typer.echo(__version__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def configure(
|
|
64
|
+
tenant_id: TenantOpt = None,
|
|
65
|
+
client_id: ClientOpt = None,
|
|
66
|
+
auth: AuthOpt = None,
|
|
67
|
+
certificate_path: CertOpt = None,
|
|
68
|
+
certificate_thumbprint: ThumbOpt = None,
|
|
69
|
+
output: Annotated[Optional[str], typer.Option("--output", "-o", help="Default output folder or s3://bucket/prefix.")] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Save defaults to ~/.power-admin/config.json so you don't pass them every time (secrets are never saved)."""
|
|
72
|
+
s = _settings(tenant_id, client_id, auth, None, certificate_path, certificate_thumbprint, output)
|
|
73
|
+
path = save_settings(s)
|
|
74
|
+
typer.echo(f"Saved settings to {path}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def login(
|
|
79
|
+
tenant_id: TenantOpt = None,
|
|
80
|
+
client_id: ClientOpt = None,
|
|
81
|
+
auth: AuthOpt = None,
|
|
82
|
+
client_secret: SecretOpt = None,
|
|
83
|
+
certificate_path: CertOpt = None,
|
|
84
|
+
certificate_thumbprint: ThumbOpt = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Sign in and cache tokens for the Power Platform API and Microsoft Graph."""
|
|
87
|
+
s = _settings(tenant_id, client_id, auth, client_secret, certificate_path, certificate_thumbprint)
|
|
88
|
+
tokens = TokenProvider(s, echo=typer.echo)
|
|
89
|
+
try:
|
|
90
|
+
tokens.get_token(PP_SCOPE)
|
|
91
|
+
typer.echo("Power Platform API: OK")
|
|
92
|
+
tokens.get_token(GRAPH_SCOPE)
|
|
93
|
+
typer.echo("Microsoft Graph: OK")
|
|
94
|
+
except AuthError as exc:
|
|
95
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
user = tokens.signed_in_user()
|
|
98
|
+
typer.echo(f"Signed in{' as ' + user if user else ''} using {s.auth_mode} auth.")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def logout() -> None:
|
|
103
|
+
"""Delete the cached sign-in."""
|
|
104
|
+
typer.echo("Signed out." if clear_cache() else "No cached sign-in found.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def datasets() -> None:
|
|
109
|
+
"""List what `pull` can collect."""
|
|
110
|
+
for c in COLLECTORS.values():
|
|
111
|
+
note = " (needs a signed-in user)" if c.delegated_only else ""
|
|
112
|
+
typer.echo(f"{c.name:<14}{c.description}{note}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def pull(
|
|
117
|
+
output: Annotated[
|
|
118
|
+
Optional[str], typer.Option("--output", "-o", help="Folder or s3://bucket/prefix. Default from config.")
|
|
119
|
+
] = None,
|
|
120
|
+
only: Annotated[Optional[list[str]], typer.Option("--only", help="Collect only these (repeatable).")] = None,
|
|
121
|
+
skip: Annotated[Optional[list[str]], typer.Option("--skip", help="Skip these (repeatable).")] = None,
|
|
122
|
+
date_from: Annotated[
|
|
123
|
+
Optional[str], typer.Option("--from", help="Start date for credit usage, YYYY-MM-DD. Default: 1st of last month.")
|
|
124
|
+
] = None,
|
|
125
|
+
date_to: Annotated[Optional[str], typer.Option("--to", help="End date, YYYY-MM-DD. Default: today.")] = None,
|
|
126
|
+
credit_entitlement: Annotated[
|
|
127
|
+
str, typer.Option(help="Licensing entitlement ID used for Copilot credit consumption.")
|
|
128
|
+
] = "MCSMessages",
|
|
129
|
+
fmt: Annotated[list[str], typer.Option("--format", "-f", help="jsonl and/or csv (repeatable).")] = ["jsonl", "csv"],
|
|
130
|
+
aws_profile: Annotated[Optional[str], typer.Option(help="AWS profile for s3:// output.")] = None,
|
|
131
|
+
s3_endpoint_url: Annotated[Optional[str], typer.Option(help="Custom S3 endpoint (e.g. MinIO).", hidden=True)] = None,
|
|
132
|
+
tenant_id: TenantOpt = None,
|
|
133
|
+
client_id: ClientOpt = None,
|
|
134
|
+
auth: AuthOpt = None,
|
|
135
|
+
client_secret: SecretOpt = None,
|
|
136
|
+
certificate_path: CertOpt = None,
|
|
137
|
+
certificate_thumbprint: ThumbOpt = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Collect admin data and save a dated snapshot."""
|
|
140
|
+
s = _settings(tenant_id, client_id, auth, client_secret, certificate_path, certificate_thumbprint, output)
|
|
141
|
+
unknown = [n for n in (only or []) + (skip or []) if n not in COLLECTORS]
|
|
142
|
+
if unknown:
|
|
143
|
+
raise typer.BadParameter(f"Unknown dataset(s): {', '.join(unknown)}. See `power-admin datasets`.")
|
|
144
|
+
bad_fmt = [f for f in fmt if f not in ("jsonl", "csv")]
|
|
145
|
+
if bad_fmt:
|
|
146
|
+
raise typer.BadParameter(f"Unknown format(s): {', '.join(bad_fmt)}")
|
|
147
|
+
|
|
148
|
+
today = date.today()
|
|
149
|
+
first_of_last_month = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
|
|
150
|
+
ctx_from, ctx_to = _parse_date(date_from, first_of_last_month), _parse_date(date_to, today)
|
|
151
|
+
|
|
152
|
+
if s.output.startswith("s3://"):
|
|
153
|
+
configure_s3(aws_profile, s3_endpoint_url)
|
|
154
|
+
|
|
155
|
+
tokens = TokenProvider(s, echo=typer.echo)
|
|
156
|
+
ctx = PullContext(ApiClient(tokens), ctx_from, ctx_to, credit_entitlement)
|
|
157
|
+
selected = [c for name, c in COLLECTORS.items() if (not only or name in only) and name not in (skip or [])]
|
|
158
|
+
|
|
159
|
+
now = datetime.now(timezone.utc)
|
|
160
|
+
snapshot = now.date().isoformat()
|
|
161
|
+
manifest: dict = {
|
|
162
|
+
"run_id": now.strftime("%Y%m%dT%H%M%SZ"),
|
|
163
|
+
"started": now.isoformat(),
|
|
164
|
+
"auth_mode": s.auth_mode,
|
|
165
|
+
"period": {"from": ctx_from.isoformat(), "to": ctx_to.isoformat()},
|
|
166
|
+
"datasets": {},
|
|
167
|
+
"errors": {},
|
|
168
|
+
}
|
|
169
|
+
results: dict[str, list[dict]] = {}
|
|
170
|
+
for c in selected:
|
|
171
|
+
if c.delegated_only and s.auth_mode in APP_ONLY_MODES:
|
|
172
|
+
msg = "skipped: needs a signed-in user (use interactive or device-code auth)"
|
|
173
|
+
manifest["errors"][c.name] = msg
|
|
174
|
+
typer.secho(f"- {c.name}: {msg}", fg=typer.colors.YELLOW)
|
|
175
|
+
continue
|
|
176
|
+
typer.echo(f"- {c.name}: collecting...")
|
|
177
|
+
try:
|
|
178
|
+
out = c.run(ctx, results)
|
|
179
|
+
except (ApiError, AuthError) as exc:
|
|
180
|
+
manifest["errors"][c.name] = str(exc)
|
|
181
|
+
typer.secho(f" failed: {exc}", fg=typer.colors.RED, err=True)
|
|
182
|
+
continue
|
|
183
|
+
for name, rows in out.items():
|
|
184
|
+
results[name] = rows
|
|
185
|
+
write_dataset(s.output, name, snapshot, rows, formats=tuple(fmt))
|
|
186
|
+
manifest["datasets"][name] = len(rows)
|
|
187
|
+
typer.echo(f" {name}: {len(rows)} rows")
|
|
188
|
+
|
|
189
|
+
manifest["finished"] = datetime.now(timezone.utc).isoformat()
|
|
190
|
+
path = write_manifest(s.output, manifest["run_id"], manifest)
|
|
191
|
+
typer.echo(f"Saved to {s.output} (manifest: {path})")
|
|
192
|
+
if manifest["errors"]:
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.command()
|
|
197
|
+
def status() -> None:
|
|
198
|
+
"""Show the active settings and cached sign-in."""
|
|
199
|
+
s = load_settings()
|
|
200
|
+
typer.echo(f"Config file: {CONFIG_FILE}{'' if CONFIG_FILE.exists() else ' (not created yet)'}")
|
|
201
|
+
for k in ("tenant_id", "client_id", "auth_mode", "output"):
|
|
202
|
+
typer.echo(f"{k:<12}{getattr(s, k) or '-'}")
|
|
203
|
+
if s.auth_mode in ("interactive", "device-code"):
|
|
204
|
+
typer.echo(f"{'signed in':<12}{TokenProvider(s).signed_in_user() or 'no'}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
app()
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Collectors: each pulls one area of admin data and returns {dataset_name: rows}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from .config import GRAPH_API, GRAPH_SCOPE, POWER_PLATFORM_API, PP_SCOPE
|
|
10
|
+
from .http import ApiClient
|
|
11
|
+
|
|
12
|
+
PP_API_VERSION = "2024-10-01"
|
|
13
|
+
|
|
14
|
+
# Resource types in the PowerPlatformResources inventory table.
|
|
15
|
+
INVENTORY_TYPES = {
|
|
16
|
+
"microsoft.powerapps/canvasapps": "app",
|
|
17
|
+
"microsoft.powerapps/modeldrivenapps": "app",
|
|
18
|
+
"microsoft.powerapps/codeapps": "app",
|
|
19
|
+
"microsoft.powerapps/apps": "app",
|
|
20
|
+
"microsoft.powerautomate/cloudflows": "flow",
|
|
21
|
+
"microsoft.powerautomate/agentflows": "flow",
|
|
22
|
+
"microsoft.powerautomate/m365agentflows": "flow",
|
|
23
|
+
"microsoft.copilotstudio/agents": "agent",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
GRAPH_USER_FIELDS = (
|
|
27
|
+
"id,displayName,userPrincipalName,mail,department,jobTitle,companyName,"
|
|
28
|
+
"officeLocation,country,accountEnabled,userType,createdDateTime,assignedLicenses"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PullContext:
|
|
34
|
+
client: ApiClient
|
|
35
|
+
date_from: date
|
|
36
|
+
date_to: date
|
|
37
|
+
credit_entitlement: str = "MCSMessages"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Collector:
|
|
42
|
+
name: str
|
|
43
|
+
description: str
|
|
44
|
+
run: Callable[[PullContext, dict[str, list[dict]]], dict[str, list[dict]]]
|
|
45
|
+
delegated_only: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _pp(path: str) -> str:
|
|
49
|
+
sep = "&" if "?" in path else "?"
|
|
50
|
+
return f"{POWER_PLATFORM_API}{path}{sep}api-version={PP_API_VERSION}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def collect_environments(ctx: PullContext, _: dict) -> dict[str, list[dict]]:
|
|
54
|
+
rows = list(ctx.client.iter_odata(_pp("/environmentmanagement/environments"), PP_SCOPE))
|
|
55
|
+
return {"environments": rows}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def inventory_query(skip_token: str | None = None) -> dict:
|
|
59
|
+
"""All apps, flows and agents joined with their environment (the admin center's default query)."""
|
|
60
|
+
return {
|
|
61
|
+
"TableName": "PowerPlatformResources",
|
|
62
|
+
"Options": {"Top": 1000, "Skip": 0, "SkipToken": skip_token or ""},
|
|
63
|
+
"Clauses": [
|
|
64
|
+
{"$type": "extend", "FieldName": "joinKey", "Expression": "tolower(tostring(properties.environmentId))"},
|
|
65
|
+
{
|
|
66
|
+
"$type": "join",
|
|
67
|
+
"JoinKind": "leftouter",
|
|
68
|
+
"RightTable": {
|
|
69
|
+
"TableName": "PowerPlatformResources",
|
|
70
|
+
"Clauses": [
|
|
71
|
+
{
|
|
72
|
+
"$type": "where",
|
|
73
|
+
"FieldName": "type",
|
|
74
|
+
"Operator": "==",
|
|
75
|
+
"Values": ["'microsoft.powerplatform/environments'"],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"$type": "project",
|
|
79
|
+
"FieldList": [
|
|
80
|
+
"joinKey = tolower(name)",
|
|
81
|
+
"environmentName = properties.displayName",
|
|
82
|
+
"environmentRegion = location",
|
|
83
|
+
"environmentType = properties.environmentType",
|
|
84
|
+
"isManagedEnvironment = properties.isManaged",
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
"LeftColumnName": "joinKey",
|
|
90
|
+
"RightColumnName": "joinKey",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"$type": "where",
|
|
94
|
+
"FieldName": "type",
|
|
95
|
+
"Operator": "in~",
|
|
96
|
+
"Values": [f"'{t}'" for t in INVENTORY_TYPES],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def collect_inventory(ctx: PullContext, _: dict) -> dict[str, list[dict]]:
|
|
103
|
+
url = _pp("/resourcequery/resources/query")
|
|
104
|
+
rows: list[dict] = []
|
|
105
|
+
skip_token = None
|
|
106
|
+
while True:
|
|
107
|
+
page = ctx.client.post_json(url, PP_SCOPE, inventory_query(skip_token)) or {}
|
|
108
|
+
rows.extend(page.get("data", []))
|
|
109
|
+
skip_token = page.get("skipToken")
|
|
110
|
+
if not skip_token:
|
|
111
|
+
break
|
|
112
|
+
out: dict[str, list[dict]] = {"inventory": rows, "agents": [], "apps": [], "flows": []}
|
|
113
|
+
for row in rows:
|
|
114
|
+
kind = INVENTORY_TYPES.get(str(row.get("type", "")).lower())
|
|
115
|
+
if kind:
|
|
116
|
+
out[kind + "s"].append(row)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def collect_capacity(ctx: PullContext, _: dict) -> dict[str, list[dict]]:
|
|
121
|
+
details = ctx.client.get_json(_pp("/licensing/tenantCapacity"), PP_SCOPE) or {}
|
|
122
|
+
rows = []
|
|
123
|
+
for cap in details.get("tenantCapacities", []):
|
|
124
|
+
consumption = cap.get("consumption") or {}
|
|
125
|
+
rows.append(
|
|
126
|
+
{
|
|
127
|
+
"capacityType": cap.get("capacityType"),
|
|
128
|
+
"capacityUnits": cap.get("capacityUnits"),
|
|
129
|
+
"status": cap.get("status"),
|
|
130
|
+
"totalCapacity": cap.get("totalCapacity"),
|
|
131
|
+
"maxCapacity": cap.get("maxCapacity"),
|
|
132
|
+
"consumedActual": consumption.get("actual"),
|
|
133
|
+
"consumedRated": consumption.get("rated"),
|
|
134
|
+
"consumedUpdatedOn": consumption.get("actualUpdatedOn"),
|
|
135
|
+
"capacityEntitlements": cap.get("capacityEntitlements"),
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
currency = ctx.client.get_json(
|
|
139
|
+
_pp("/licensing/tenantCapacity/currencyReports?includeAllocations=true&includeConsumptions=true"), PP_SCOPE
|
|
140
|
+
)
|
|
141
|
+
return {"capacity": rows, "capacity_raw": [details], "currency": currency or []}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def collect_credits(ctx: PullContext, so_far: dict) -> dict[str, list[dict]]:
|
|
145
|
+
"""Copilot credit consumption per resource (agent) across all environments."""
|
|
146
|
+
base = _pp(f"/licensing/entitlements/{ctx.credit_entitlement}/resources")
|
|
147
|
+
params: dict = {"fromDate": ctx.date_from.isoformat(), "toDate": ctx.date_to.isoformat()}
|
|
148
|
+
rows: list[dict] = []
|
|
149
|
+
while True:
|
|
150
|
+
page = ctx.client.get_json(base, PP_SCOPE, params=params) or {}
|
|
151
|
+
rows.extend(page.get("value", []))
|
|
152
|
+
token = page.get("continuationtoken") or page.get("continuationToken")
|
|
153
|
+
if not token:
|
|
154
|
+
break
|
|
155
|
+
params = {**params, "continuationToken": token}
|
|
156
|
+
|
|
157
|
+
# Attach agent names when inventory ran in the same pull.
|
|
158
|
+
names = {
|
|
159
|
+
str(a.get("name", "")).lower(): (a.get("properties") or {}).get("displayName") for a in so_far.get("agents", [])
|
|
160
|
+
}
|
|
161
|
+
envs = {
|
|
162
|
+
str(e.get("id", e.get("name", ""))).lower(): e.get("displayName") or (e.get("properties") or {}).get("displayName")
|
|
163
|
+
for e in so_far.get("environments", [])
|
|
164
|
+
}
|
|
165
|
+
for row in rows:
|
|
166
|
+
row["resourceName"] = names.get(str(row.get("resourceId", "")).lower())
|
|
167
|
+
row["environmentName"] = envs.get(str(row.get("environmentId", "")).lower())
|
|
168
|
+
row["periodFrom"] = ctx.date_from.isoformat()
|
|
169
|
+
row["periodTo"] = ctx.date_to.isoformat()
|
|
170
|
+
return {"agent_credits": rows}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def collect_users(ctx: PullContext, _: dict) -> dict[str, list[dict]]:
|
|
174
|
+
users = list(
|
|
175
|
+
ctx.client.iter_odata(
|
|
176
|
+
f"{GRAPH_API}/v1.0/users", GRAPH_SCOPE, params={"$select": GRAPH_USER_FIELDS, "$top": "999"}
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
skus = list(ctx.client.iter_odata(f"{GRAPH_API}/v1.0/subscribedSkus", GRAPH_SCOPE))
|
|
180
|
+
sku_names = {s.get("skuId"): s.get("skuPartNumber") for s in skus}
|
|
181
|
+
user_licenses = [
|
|
182
|
+
{
|
|
183
|
+
"userId": u.get("id"),
|
|
184
|
+
"userPrincipalName": u.get("userPrincipalName"),
|
|
185
|
+
"skuId": lic.get("skuId"),
|
|
186
|
+
"skuPartNumber": sku_names.get(lic.get("skuId")),
|
|
187
|
+
}
|
|
188
|
+
for u in users
|
|
189
|
+
for lic in u.get("assignedLicenses") or []
|
|
190
|
+
]
|
|
191
|
+
return {"users": users, "licenses": skus, "user_licenses": user_licenses}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Order matters: credits uses names from inventory and environments.
|
|
195
|
+
COLLECTORS: dict[str, Collector] = {
|
|
196
|
+
c.name: c
|
|
197
|
+
for c in [
|
|
198
|
+
Collector("environments", "Environments with type, region, Dataverse and capacity details", collect_environments),
|
|
199
|
+
Collector(
|
|
200
|
+
"inventory",
|
|
201
|
+
"All apps, flows and Copilot Studio agents (also split into agents/apps/flows)",
|
|
202
|
+
collect_inventory,
|
|
203
|
+
delegated_only=True,
|
|
204
|
+
),
|
|
205
|
+
Collector("capacity", "Tenant storage/API capacity and currency (credit) reports", collect_capacity),
|
|
206
|
+
Collector("credits", "Copilot credit consumption per agent for the date range", collect_credits),
|
|
207
|
+
Collector("users", "Entra users, tenant license SKUs, and per-user license assignments", collect_users),
|
|
208
|
+
]
|
|
209
|
+
}
|
power_admin/config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Settings loaded from flags, environment variables and ~/.power-admin/config.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass, fields
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path(os.environ.get("POWER_ADMIN_HOME", Path.home() / ".power-admin"))
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
12
|
+
TOKEN_CACHE_FILE = CONFIG_DIR / "msal_token_cache.json"
|
|
13
|
+
|
|
14
|
+
POWER_PLATFORM_API = "https://api.powerplatform.com"
|
|
15
|
+
GRAPH_API = "https://graph.microsoft.com"
|
|
16
|
+
PP_SCOPE = f"{POWER_PLATFORM_API}/.default"
|
|
17
|
+
GRAPH_SCOPE = f"{GRAPH_API}/.default"
|
|
18
|
+
|
|
19
|
+
AUTH_MODES = ("interactive", "device-code", "client-secret", "certificate", "token")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Settings:
|
|
24
|
+
tenant_id: str | None = None
|
|
25
|
+
client_id: str | None = None
|
|
26
|
+
auth_mode: str = "interactive"
|
|
27
|
+
# Service principal options. The secret is never written to the config file.
|
|
28
|
+
client_secret: str | None = None
|
|
29
|
+
certificate_path: str | None = None
|
|
30
|
+
certificate_thumbprint: str | None = None
|
|
31
|
+
# Default output location: a local folder or s3://bucket/prefix
|
|
32
|
+
output: str = "./power-admin-output"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def authority(self) -> str:
|
|
36
|
+
return f"https://login.microsoftonline.com/{self.tenant_id or 'organizations'}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_ENV = {
|
|
40
|
+
"tenant_id": "POWER_ADMIN_TENANT_ID",
|
|
41
|
+
"client_id": "POWER_ADMIN_CLIENT_ID",
|
|
42
|
+
"auth_mode": "POWER_ADMIN_AUTH_MODE",
|
|
43
|
+
"client_secret": "POWER_ADMIN_CLIENT_SECRET",
|
|
44
|
+
"certificate_path": "POWER_ADMIN_CERTIFICATE_PATH",
|
|
45
|
+
"certificate_thumbprint": "POWER_ADMIN_CERTIFICATE_THUMBPRINT",
|
|
46
|
+
"output": "POWER_ADMIN_OUTPUT",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_SECRET_FIELDS = {"client_secret"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_settings(**overrides: str | None) -> Settings:
|
|
53
|
+
"""Config file < environment variables < explicit overrides (CLI flags)."""
|
|
54
|
+
values: dict[str, str] = {}
|
|
55
|
+
if CONFIG_FILE.exists():
|
|
56
|
+
values.update(json.loads(CONFIG_FILE.read_text(encoding="utf-8")))
|
|
57
|
+
for name, env in _ENV.items():
|
|
58
|
+
if os.environ.get(env):
|
|
59
|
+
values[name] = os.environ[env]
|
|
60
|
+
values.update({k: v for k, v in overrides.items() if v is not None})
|
|
61
|
+
known = {f.name for f in fields(Settings)}
|
|
62
|
+
settings = Settings(**{k: v for k, v in values.items() if k in known})
|
|
63
|
+
if settings.auth_mode not in AUTH_MODES:
|
|
64
|
+
raise ValueError(f"Unknown auth mode {settings.auth_mode!r}; choose one of {', '.join(AUTH_MODES)}")
|
|
65
|
+
return settings
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_settings(settings: Settings) -> Path:
|
|
69
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
data = {k: v for k, v in asdict(settings).items() if v is not None and k not in _SECRET_FIELDS}
|
|
71
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
72
|
+
return CONFIG_FILE
|
power_admin/http.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Thin HTTP client with bearer auth, retries on throttling, and paging helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Iterator
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .auth import TokenProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiError(RuntimeError):
|
|
14
|
+
def __init__(self, status: int, url: str, body: str):
|
|
15
|
+
self.status = status
|
|
16
|
+
super().__init__(f"HTTP {status} from {url}: {body[:500]}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApiClient:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
tokens: TokenProvider,
|
|
23
|
+
session: requests.Session | None = None,
|
|
24
|
+
max_retries: int = 5,
|
|
25
|
+
sleep=time.sleep,
|
|
26
|
+
):
|
|
27
|
+
self.tokens = tokens
|
|
28
|
+
self.session = session or requests.Session()
|
|
29
|
+
self.max_retries = max_retries
|
|
30
|
+
self._sleep = sleep
|
|
31
|
+
|
|
32
|
+
def request(self, method: str, url: str, scope: str, **kwargs: Any) -> requests.Response:
|
|
33
|
+
headers = {"Authorization": f"Bearer {self.tokens.get_token(scope)}", "Accept": "application/json"}
|
|
34
|
+
headers.update(kwargs.pop("headers", {}) or {})
|
|
35
|
+
for attempt in range(self.max_retries + 1):
|
|
36
|
+
resp = self.session.request(method, url, headers=headers, timeout=120, **kwargs)
|
|
37
|
+
if resp.status_code in (429, 500, 502, 503, 504) and attempt < self.max_retries:
|
|
38
|
+
retry_after = resp.headers.get("Retry-After")
|
|
39
|
+
delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
|
|
40
|
+
self._sleep(min(delay, 60))
|
|
41
|
+
continue
|
|
42
|
+
if resp.status_code >= 400:
|
|
43
|
+
raise ApiError(resp.status_code, url, resp.text)
|
|
44
|
+
return resp
|
|
45
|
+
raise AssertionError("unreachable")
|
|
46
|
+
|
|
47
|
+
def get_json(self, url: str, scope: str, **kwargs: Any) -> Any:
|
|
48
|
+
resp = self.request("GET", url, scope, **kwargs)
|
|
49
|
+
return resp.json() if resp.content else None
|
|
50
|
+
|
|
51
|
+
def post_json(self, url: str, scope: str, body: Any) -> Any:
|
|
52
|
+
resp = self.request("POST", url, scope, json=body)
|
|
53
|
+
return resp.json() if resp.content else None
|
|
54
|
+
|
|
55
|
+
def iter_odata(self, url: str, scope: str, params: dict | None = None) -> Iterator[dict]:
|
|
56
|
+
"""Follow @odata.nextLink (Graph and most Power Platform list endpoints)."""
|
|
57
|
+
next_url: str | None = url
|
|
58
|
+
while next_url:
|
|
59
|
+
page = self.get_json(next_url, scope, params=params) or {}
|
|
60
|
+
params = None # nextLink already carries the query string
|
|
61
|
+
yield from page.get("value", [])
|
|
62
|
+
next_url = page.get("@odata.nextLink")
|
power_admin/storage.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Write datasets to a local folder or S3 using cloudpathlib.
|
|
2
|
+
|
|
3
|
+
Layout (Athena/Glue friendly, partitioned by snapshot date):
|
|
4
|
+
<output>/<dataset>/snapshot_date=YYYY-MM-DD/<dataset>.jsonl
|
|
5
|
+
<output>/<dataset>/snapshot_date=YYYY-MM-DD/<dataset>.csv
|
|
6
|
+
<output>/_runs/<run_id>.json run manifest (counts, errors)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Iterable
|
|
15
|
+
|
|
16
|
+
from cloudpathlib import AnyPath, S3Client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def configure_s3(profile: str | None = None, endpoint_url: str | None = None) -> None:
|
|
20
|
+
"""Make s3:// paths use a named AWS profile (otherwise the default AWS credential chain)."""
|
|
21
|
+
if profile or endpoint_url:
|
|
22
|
+
S3Client(profile_name=profile, endpoint_url=endpoint_url).set_as_default_client()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def flatten(row: dict, prefix: str = "") -> dict[str, Any]:
|
|
26
|
+
out: dict[str, Any] = {}
|
|
27
|
+
for key, value in row.items():
|
|
28
|
+
name = f"{prefix}{key}"
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
out.update(flatten(value, name + "."))
|
|
31
|
+
elif isinstance(value, list):
|
|
32
|
+
out[name] = json.dumps(value, ensure_ascii=False)
|
|
33
|
+
else:
|
|
34
|
+
out[name] = value
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_csv(rows: Iterable[dict]) -> str:
|
|
39
|
+
flat = [flatten(r) for r in rows]
|
|
40
|
+
columns: list[str] = []
|
|
41
|
+
seen: set[str] = set()
|
|
42
|
+
for r in flat:
|
|
43
|
+
for k in r:
|
|
44
|
+
if k not in seen:
|
|
45
|
+
seen.add(k)
|
|
46
|
+
columns.append(k)
|
|
47
|
+
buf = io.StringIO()
|
|
48
|
+
writer = csv.DictWriter(buf, fieldnames=columns, extrasaction="ignore")
|
|
49
|
+
writer.writeheader()
|
|
50
|
+
writer.writerows(flat)
|
|
51
|
+
return buf.getvalue()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def to_jsonl(rows: Iterable[dict]) -> str:
|
|
55
|
+
return "".join(json.dumps(r, ensure_ascii=False, default=str) + "\n" for r in rows)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_dataset(output: str, dataset: str, snapshot_date: str, rows: list[dict], formats=("jsonl", "csv")) -> list[str]:
|
|
59
|
+
folder = AnyPath(output) / dataset / f"snapshot_date={snapshot_date}"
|
|
60
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
written = []
|
|
62
|
+
for fmt in formats:
|
|
63
|
+
target = folder / f"{dataset}.{fmt}"
|
|
64
|
+
target.write_text(to_jsonl(rows) if fmt == "jsonl" else to_csv(rows), encoding="utf-8")
|
|
65
|
+
written.append(str(target))
|
|
66
|
+
return written
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_manifest(output: str, run_id: str, manifest: dict) -> str:
|
|
70
|
+
folder = AnyPath(output) / "_runs"
|
|
71
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
target = folder / f"{run_id}.json"
|
|
73
|
+
target.write_text(json.dumps(manifest, indent=2, default=str), encoding="utf-8")
|
|
74
|
+
return str(target)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: power-admin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pull Power Platform admin data (inventory, capacity, Copilot credits, users) to a local folder or S3.
|
|
5
|
+
Project-URL: Homepage, https://github.com/emptymalei/power-admin
|
|
6
|
+
Project-URL: Issues, https://github.com/emptymalei/power-admin/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: admin,copilot-studio,governance,inventory,power-apps,power-automate,power-platform
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: System :: Systems Administration
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: cloudpathlib[s3]>=0.18
|
|
16
|
+
Requires-Dist: msal>=1.28
|
|
17
|
+
Requires-Dist: requests>=2.31
|
|
18
|
+
Requires-Dist: typer>=0.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# power-admin
|
|
22
|
+
|
|
23
|
+
A small CLI that signs in to your Microsoft tenant and pulls Power Platform admin data into dated snapshots. You can save to a local folder or to S3.
|
|
24
|
+
|
|
25
|
+
> Community tool. Not affiliated with or endorsed by Microsoft.
|
|
26
|
+
|
|
27
|
+
| Dataset | What's in it | Source |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| `environments` | Environments, their type and region, Dataverse details | Power Platform API `environmentmanagement` |
|
|
30
|
+
| `inventory`, `agents`, `apps`, `flows` | Every canvas, model-driven and code app, every cloud and agent flow, and every Copilot Studio agent, joined to its environment | Power Platform inventory API (`PowerPlatformResources`) |
|
|
31
|
+
| `capacity`, `capacity_raw`, `currency` | Tenant storage and API capacity, plus purchased, allocated and consumed amounts per currency (Copilot credits, AI Builder and others) | Power Platform API `licensing/tenantCapacity` |
|
|
32
|
+
| `agent_credits` | Copilot credit consumption per agent and environment for a date range, with agent names attached | Power Platform API `licensing/entitlements/{id}/resources` |
|
|
33
|
+
| `users`, `licenses`, `user_licenses` | Entra users, the tenant's license SKUs, and which user has which SKU | Microsoft Graph |
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
You need [uv](https://docs.astral.sh/uv/). In PowerShell:
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # if you don't have uv
|
|
41
|
+
uvx power-admin --help # run without installing (once published to PyPI)
|
|
42
|
+
uv tool install power-admin # or put `power-admin` on your PATH
|
|
43
|
+
# from a local checkout: uv tool install .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Setup
|
|
47
|
+
|
|
48
|
+
You don't need your own Entra app. For `interactive` and `device-code` sign-in, power-admin uses the public client ID of Microsoft's Azure PowerShell app (`1950a258-227b-4e31-a9cf-717495945fc2`), which is the same one `Connect-AzAccount` uses. That app is already pre-authorized for the Power Platform API and Microsoft Graph, and it doesn't need an Azure subscription. Data access still comes from your own admin roles.
|
|
49
|
+
|
|
50
|
+
```powershell
|
|
51
|
+
power-admin configure --tenant-id contoso.onmicrosoft.com --output C:\power-admin
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Your account needs the Power Platform Administrator role (or Global Administrator / Global Reader) so it can see the whole tenant. Graph reads need a role that can read users, such as Global Reader or User Administrator.
|
|
55
|
+
|
|
56
|
+
### Optional: use your own app registration
|
|
57
|
+
|
|
58
|
+
Use your own app if your tenant blocks the Azure PowerShell client through Conditional Access, if you want consent scoped to exactly these permissions, or if you need app-only auth.
|
|
59
|
+
|
|
60
|
+
1. Go to **Entra admin center > App registrations > New registration**. Choose **single tenant**.
|
|
61
|
+
2. Under **Authentication**, add the platform **Mobile and desktop applications** with the redirect URI `http://localhost`. If you'll use device-code sign-in, also set **Allow public client flows** to **Yes**.
|
|
62
|
+
3. Under **API permissions**, add the following **delegated** permissions, then click **Grant admin consent**:
|
|
63
|
+
- **Power Platform API** (`8578e004-a5c6-46e7-913e-12f58912df43`): `ResourceQuery.Resources.Read`, `EnvironmentManagement.Environments.Read`, `Licensing.Allocations.Read`.
|
|
64
|
+
If the API doesn't show up in the list, run `New-MgServicePrincipal -AppId 8578e004-a5c6-46e7-913e-12f58912df43` once, using Microsoft Graph PowerShell.
|
|
65
|
+
- **Microsoft Graph**: `User.Read.All`, `Organization.Read.All`.
|
|
66
|
+
4. Pass `--client-id <app-id>` to `power-admin configure`.
|
|
67
|
+
|
|
68
|
+
### Or reuse an existing PowerShell sign-in
|
|
69
|
+
|
|
70
|
+
If you're already signed in with the Az module, you can hand those tokens to power-admin:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
Connect-AzAccount -Tenant contoso.onmicrosoft.com # warns that there are no subscriptions, which is fine
|
|
74
|
+
$pp = Get-AzAccessToken -ResourceUrl "https://api.powerplatform.com" -AsSecureString
|
|
75
|
+
$gr = Get-AzAccessToken -ResourceTypeName MSGraph -AsSecureString
|
|
76
|
+
$env:POWER_ADMIN_PP_TOKEN = $pp.Token | ConvertFrom-SecureString -AsPlainText
|
|
77
|
+
$env:POWER_ADMIN_GRAPH_TOKEN = $gr.Token | ConvertFrom-SecureString -AsPlainText
|
|
78
|
+
power-admin pull --auth token
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Tokens last about an hour.
|
|
82
|
+
|
|
83
|
+
## Use
|
|
84
|
+
|
|
85
|
+
```powershell
|
|
86
|
+
power-admin login # opens a browser; the sign-in is cached in ~/.power-admin
|
|
87
|
+
power-admin pull # everything, credits from the 1st of last month to today
|
|
88
|
+
power-admin pull -o s3://my-bucket/power-platform --aws-profile admin
|
|
89
|
+
power-admin pull --only credits --from 2026-10-01 --to 2026-10-31
|
|
90
|
+
power-admin pull --skip users -f csv # CSV only
|
|
91
|
+
power-admin datasets # list what can be pulled
|
|
92
|
+
power-admin status # show settings and who's signed in
|
|
93
|
+
power-admin logout
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Output layout. It's the same for a local folder and for S3, and it's partitioned so that Athena and Glue can read it directly:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
<output>/agents/snapshot_date=2026-10-30/agents.jsonl
|
|
100
|
+
<output>/agents/snapshot_date=2026-10-30/agents.csv
|
|
101
|
+
<output>/agent_credits/snapshot_date=2026-10-30/agent_credits.csv
|
|
102
|
+
<output>/_runs/20261030T212400Z.json # row counts and any errors for the run
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Every run adds a new `snapshot_date`, so you build up history. If any dataset fails, the run still saves the rest, records the error in the manifest, and exits with code 1.
|
|
106
|
+
|
|
107
|
+
S3 uploads use the normal AWS credential chain, or the profile you pass with `--aws-profile`. Run `aws sso login --profile admin` first if you use SSO.
|
|
108
|
+
|
|
109
|
+
## Auth modes
|
|
110
|
+
|
|
111
|
+
Pick a mode with `--auth` / `-a`, or set it in `power-admin configure` or with `POWER_ADMIN_AUTH_MODE`.
|
|
112
|
+
|
|
113
|
+
| Mode | When to use | Needs |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| `interactive` (default) | You, at your desk. Opens a browser. | `--tenant-id` (`--client-id` is optional) |
|
|
116
|
+
| `device-code` | Remote or SSH shells, or when a browser can't open. You enter a code at microsoft.com/devicelogin. | Same (with your own app, also turn on **Allow public client flows**) |
|
|
117
|
+
| `client-secret` | Unattended runs | `--tenant-id`, `--client-id`, `POWER_ADMIN_CLIENT_SECRET` |
|
|
118
|
+
| `certificate` | Unattended runs without secrets | `--certificate-path` (PEM private key) and `--certificate-thumbprint` |
|
|
119
|
+
| `token` | You already signed in with `Connect-AzAccount` or another tool | `POWER_ADMIN_PP_TOKEN` and `POWER_ADMIN_GRAPH_TOKEN` env vars |
|
|
120
|
+
|
|
121
|
+
How the app-only modes (`client-secret`, `certificate`) differ:
|
|
122
|
+
- **The inventory API rejects app-only tokens.** Microsoft only supports signed-in users there, so in these modes `inventory` is skipped and listed in the manifest.
|
|
123
|
+
- **The service principal needs extra access.** It must be given a Power Platform RBAC role (see Microsoft's guide *Assign RBAC roles to service principals*), and for `users` it needs the Graph *application* permissions `User.Read.All` and `Organization.Read.All`.
|
|
124
|
+
|
|
125
|
+
For monthly manual runs, `interactive` is the simplest option.
|
|
126
|
+
|
|
127
|
+
## Caveats
|
|
128
|
+
|
|
129
|
+
- **The per-agent credit endpoints may change or fail.** They are marked *deprecated* in the Power Platform API reference but still documented. The entitlement ID `MCSMessages` (Copilot credits) is our best reading of the docs and hasn't been tested against a live tenant yet; if you get a 400 or 404, try `--credit-entitlement` with another value. Whatever happens, the admin center's **Licensing > Copilot Studio > Download report** remains the authoritative source.
|
|
130
|
+
- **Several licensing APIs are still in preview**, so their field names may change. JSONL keeps the full raw response; CSV flattens nested fields into `a.b.c` columns.
|
|
131
|
+
- **Usage metrics are missing.** Per-app users, per-flow runs and per-agent sessions from the admin center's Usage page don't have a public API yet.
|
|
132
|
+
|
|
133
|
+
## Querying in Athena (optional)
|
|
134
|
+
|
|
135
|
+
```sql
|
|
136
|
+
CREATE EXTERNAL TABLE agent_credits (
|
|
137
|
+
resourceId string, resourceName string, environmentId string, environmentName string,
|
|
138
|
+
consumed double, unit string, lastRefreshedDate string, periodFrom string, periodTo string
|
|
139
|
+
)
|
|
140
|
+
PARTITIONED BY (snapshot_date string)
|
|
141
|
+
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
|
|
142
|
+
LOCATION 's3://my-bucket/power-platform/agent_credits/';
|
|
143
|
+
MSCK REPAIR TABLE agent_credits;
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Develop
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
uv sync
|
|
150
|
+
uv run pytest
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Releasing
|
|
154
|
+
|
|
155
|
+
CI runs the tests on every push and pull request. Publishing a GitHub release whose tag is `v<version>` (for example `v0.1.0`, matching `version` in `pyproject.toml`) builds the package and uploads it to PyPI.
|
|
156
|
+
|
|
157
|
+
The release uses PyPI trusted publishing, so no token is stored in GitHub. One-time setup:
|
|
158
|
+
1. Add a pending publisher on PyPI under **Your account > Publishing**, with project `power-admin`, owner `emptymalei`, repository `power-admin`, workflow `release.yml` and environment `pypi`.
|
|
159
|
+
2. In the GitHub repo, open **Settings > Environments** and create an environment called `pypi`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
power_admin/__init__.py,sha256=gMorSrTk6qwi3VBYf6FbFpc8CBcMXB6YwOerfnlSJ8U,63
|
|
2
|
+
power_admin/auth.py,sha256=RRiuTozFh_e_MfwrMg_fv7MmyjSEhPmQVcS95OiVwBM,6369
|
|
3
|
+
power_admin/cli.py,sha256=1ii_KuNjMesVXwOSvmmlMreiFKwkw7vBb6zOXtOKzQw,8715
|
|
4
|
+
power_admin/collectors.py,sha256=DVtoeGtgsC3jkl26y-NiX61AZe4aWkbt-ALfuf7X6xE,8256
|
|
5
|
+
power_admin/config.py,sha256=dPFGu5sPwxr7Ckv1jKv9VWFEunSDiUfgXyvRx9hBnnA,2679
|
|
6
|
+
power_admin/http.py,sha256=uMb2Bt5iAkONGjxUBX_rBDfgr9vMwxMVDwfIOYJg1TE,2481
|
|
7
|
+
power_admin/storage.py,sha256=Xh_eOGCfFbPzhGn_IB9glg0Kbw8gr4th6A6r8R02p50,2546
|
|
8
|
+
power_admin-0.1.0.dist-info/METADATA,sha256=EJhh2xJj9XJIKXepceXVyFDokQR9cEHqoBvIcVTiPFg,9466
|
|
9
|
+
power_admin-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
power_admin-0.1.0.dist-info/entry_points.txt,sha256=01Mbd9HmzpOnooHvlJ0WR7PcTMCnOw8F9m4hz_4wGZc,52
|
|
11
|
+
power_admin-0.1.0.dist-info/licenses/LICENSE,sha256=eU-CKYy3Y2UUqcjG6BOFP0GEjetNUvD9Z7x8DLPI6W4,1058
|
|
12
|
+
power_admin-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 L
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|