power-admin 0.1.0__tar.gz

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,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest, windows-latest]
15
+ python-version: ["3.10", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v6
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - run: uv sync --locked
22
+ - run: uv run pytest -q
23
+ - run: uv build
@@ -0,0 +1,31 @@
1
+ name: Release
2
+
3
+ # Publish to PyPI when a GitHub release is published (tag like v0.1.0).
4
+ # Uses PyPI trusted publishing, so no API token is stored in GitHub.
5
+ on:
6
+ release:
7
+ types: [published]
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ environment:
13
+ name: pypi
14
+ url: https://pypi.org/p/power-admin
15
+ permissions:
16
+ id-token: write
17
+ contents: read
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: astral-sh/setup-uv@v6
21
+ - name: Check tag matches package version
22
+ run: |
23
+ version=$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)
24
+ if [ "${GITHUB_REF_NAME#v}" != "$version" ]; then
25
+ echo "Tag $GITHUB_REF_NAME does not match pyproject version $version" >&2
26
+ exit 1
27
+ fi
28
+ - run: uv sync --locked
29
+ - run: uv run pytest -q
30
+ - run: uv build
31
+ - run: uv publish
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ power-admin-output/
5
+ .pytest_cache/
@@ -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.
@@ -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,139 @@
1
+ # power-admin
2
+
3
+ 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.
4
+
5
+ > Community tool. Not affiliated with or endorsed by Microsoft.
6
+
7
+ | Dataset | What's in it | Source |
8
+ | --- | --- | --- |
9
+ | `environments` | Environments, their type and region, Dataverse details | Power Platform API `environmentmanagement` |
10
+ | `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`) |
11
+ | `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` |
12
+ | `agent_credits` | Copilot credit consumption per agent and environment for a date range, with agent names attached | Power Platform API `licensing/entitlements/{id}/resources` |
13
+ | `users`, `licenses`, `user_licenses` | Entra users, the tenant's license SKUs, and which user has which SKU | Microsoft Graph |
14
+
15
+ ## Install
16
+
17
+ You need [uv](https://docs.astral.sh/uv/). In PowerShell:
18
+
19
+ ```powershell
20
+ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # if you don't have uv
21
+ uvx power-admin --help # run without installing (once published to PyPI)
22
+ uv tool install power-admin # or put `power-admin` on your PATH
23
+ # from a local checkout: uv tool install .
24
+ ```
25
+
26
+ ## Setup
27
+
28
+ 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.
29
+
30
+ ```powershell
31
+ power-admin configure --tenant-id contoso.onmicrosoft.com --output C:\power-admin
32
+ ```
33
+
34
+ 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.
35
+
36
+ ### Optional: use your own app registration
37
+
38
+ 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.
39
+
40
+ 1. Go to **Entra admin center > App registrations > New registration**. Choose **single tenant**.
41
+ 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**.
42
+ 3. Under **API permissions**, add the following **delegated** permissions, then click **Grant admin consent**:
43
+ - **Power Platform API** (`8578e004-a5c6-46e7-913e-12f58912df43`): `ResourceQuery.Resources.Read`, `EnvironmentManagement.Environments.Read`, `Licensing.Allocations.Read`.
44
+ If the API doesn't show up in the list, run `New-MgServicePrincipal -AppId 8578e004-a5c6-46e7-913e-12f58912df43` once, using Microsoft Graph PowerShell.
45
+ - **Microsoft Graph**: `User.Read.All`, `Organization.Read.All`.
46
+ 4. Pass `--client-id <app-id>` to `power-admin configure`.
47
+
48
+ ### Or reuse an existing PowerShell sign-in
49
+
50
+ If you're already signed in with the Az module, you can hand those tokens to power-admin:
51
+
52
+ ```powershell
53
+ Connect-AzAccount -Tenant contoso.onmicrosoft.com # warns that there are no subscriptions, which is fine
54
+ $pp = Get-AzAccessToken -ResourceUrl "https://api.powerplatform.com" -AsSecureString
55
+ $gr = Get-AzAccessToken -ResourceTypeName MSGraph -AsSecureString
56
+ $env:POWER_ADMIN_PP_TOKEN = $pp.Token | ConvertFrom-SecureString -AsPlainText
57
+ $env:POWER_ADMIN_GRAPH_TOKEN = $gr.Token | ConvertFrom-SecureString -AsPlainText
58
+ power-admin pull --auth token
59
+ ```
60
+
61
+ Tokens last about an hour.
62
+
63
+ ## Use
64
+
65
+ ```powershell
66
+ power-admin login # opens a browser; the sign-in is cached in ~/.power-admin
67
+ power-admin pull # everything, credits from the 1st of last month to today
68
+ power-admin pull -o s3://my-bucket/power-platform --aws-profile admin
69
+ power-admin pull --only credits --from 2026-10-01 --to 2026-10-31
70
+ power-admin pull --skip users -f csv # CSV only
71
+ power-admin datasets # list what can be pulled
72
+ power-admin status # show settings and who's signed in
73
+ power-admin logout
74
+ ```
75
+
76
+ 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:
77
+
78
+ ```
79
+ <output>/agents/snapshot_date=2026-10-30/agents.jsonl
80
+ <output>/agents/snapshot_date=2026-10-30/agents.csv
81
+ <output>/agent_credits/snapshot_date=2026-10-30/agent_credits.csv
82
+ <output>/_runs/20261030T212400Z.json # row counts and any errors for the run
83
+ ```
84
+
85
+ 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.
86
+
87
+ 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.
88
+
89
+ ## Auth modes
90
+
91
+ Pick a mode with `--auth` / `-a`, or set it in `power-admin configure` or with `POWER_ADMIN_AUTH_MODE`.
92
+
93
+ | Mode | When to use | Needs |
94
+ | --- | --- | --- |
95
+ | `interactive` (default) | You, at your desk. Opens a browser. | `--tenant-id` (`--client-id` is optional) |
96
+ | `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**) |
97
+ | `client-secret` | Unattended runs | `--tenant-id`, `--client-id`, `POWER_ADMIN_CLIENT_SECRET` |
98
+ | `certificate` | Unattended runs without secrets | `--certificate-path` (PEM private key) and `--certificate-thumbprint` |
99
+ | `token` | You already signed in with `Connect-AzAccount` or another tool | `POWER_ADMIN_PP_TOKEN` and `POWER_ADMIN_GRAPH_TOKEN` env vars |
100
+
101
+ How the app-only modes (`client-secret`, `certificate`) differ:
102
+ - **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.
103
+ - **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`.
104
+
105
+ For monthly manual runs, `interactive` is the simplest option.
106
+
107
+ ## Caveats
108
+
109
+ - **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.
110
+ - **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.
111
+ - **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.
112
+
113
+ ## Querying in Athena (optional)
114
+
115
+ ```sql
116
+ CREATE EXTERNAL TABLE agent_credits (
117
+ resourceId string, resourceName string, environmentId string, environmentName string,
118
+ consumed double, unit string, lastRefreshedDate string, periodFrom string, periodTo string
119
+ )
120
+ PARTITIONED BY (snapshot_date string)
121
+ ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
122
+ LOCATION 's3://my-bucket/power-platform/agent_credits/';
123
+ MSCK REPAIR TABLE agent_credits;
124
+ ```
125
+
126
+ ## Develop
127
+
128
+ ```bash
129
+ uv sync
130
+ uv run pytest
131
+ ```
132
+
133
+ ## Releasing
134
+
135
+ 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.
136
+
137
+ The release uses PyPI trusted publishing, so no token is stored in GitHub. One-time setup:
138
+ 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`.
139
+ 2. In the GitHub repo, open **Settings > Environments** and create an environment called `pypi`.
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "power-admin"
3
+ version = "0.1.0"
4
+ description = "Pull Power Platform admin data (inventory, capacity, Copilot credits, users) to a local folder or S3."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = ["power-platform", "copilot-studio", "power-apps", "power-automate", "admin", "inventory", "governance"]
8
+ classifiers = [
9
+ "Environment :: Console",
10
+ "Intended Audience :: System Administrators",
11
+ "Programming Language :: Python :: 3",
12
+ "Topic :: System :: Systems Administration",
13
+ ]
14
+ requires-python = ">=3.10"
15
+ dependencies = [
16
+ "typer>=0.12",
17
+ "msal>=1.28",
18
+ "requests>=2.31",
19
+ "cloudpathlib[s3]>=0.18",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/emptymalei/power-admin"
24
+ Issues = "https://github.com/emptymalei/power-admin/issues"
25
+
26
+ [project.scripts]
27
+ power-admin = "power_admin.cli:app"
28
+
29
+ [dependency-groups]
30
+ dev = ["pytest>=8"]
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/power_admin"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """Power Platform admin data puller."""
2
+
3
+ __version__ = "0.1.0"
@@ -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