# Elevate CLI The command-line counterpart of the macOS and Windows apps: just-in-time Microsoft Entra and Azure PIM role activation from a terminal, on Linux, macOS and Windows. One self-contained `elevate` binary per platform, built with .NET 10 on the same `Elevate.Core` the Windows app uses, so roles, activation, profiles and approvals behave the same way and read the same `state.json` schema. ```text $ elevate roles --tenant contoso ╭──────────┬──────────────────────────┬───────┬────────────────────┬─────────┬────────────────────┬───────────────────────╮ │ ID │ Role │ Kind │ Scope │ Tenant │ Policy │ Status │ ├──────────┼──────────────────────────┼───────┼────────────────────┼─────────┼────────────────────┼───────────────────────┤ │ 3f9a1c2e │ Global Reader │ Entra │ — │ Contoso │ 8 h · MFA │ eligible │ │ b71d0e44 │ Contributor │ Azure │ Prod · subscription│ Contoso │ 2 h · approval │ awaiting approval │ │ 9c02aa17 │ Platform Admins (member) │ Group │ member │ Contoso │ 4 h │ active 2 h 41 min left│ ╰──────────┴──────────────────────────┴───────┴────────────────────┴─────────┴────────────────────┴───────────────────────╯ $ elevate activate "Global Reader" --duration 2h --reason "Support ticket 4211" ``` ## Install **With the app (macOS on Apple Silicon, Windows).** The Homebrew cask and the macOS installer package put the CLI on your PATH as `/usr/local/bin/elevate` (it lives inside `Elevate.app/Contents/Helpers`); the Windows MSI installs `elevate.exe` in the `cli` folder under the app and adds that folder to your user PATH. Install the app and you have the CLI at the same version: [macos/README.md](../macos/README.md#install), [windows/README.md](../windows/README.md#install). If you previously installed the `elevate-cli` formula, run `brew uninstall frodehus/elevate/elevate-cli` — its `/opt/homebrew/bin/elevate` comes before `/usr/local/bin` on the PATH and would keep running the old binary. **Homebrew formula (Linux, Intel Macs).** The formula lives in this repository, which doubles as a tap. It is **deprecated**: the cask now carries the CLI, and the formula will be removed in a later release. Linux and Intel Macs keep the archives below. ```bash brew tap FrodeHus/elevate https://github.com/FrodeHus/elevate brew trust frodehus/elevate # Homebrew 6 requires trusting third-party taps brew install frodehus/elevate/elevate-cli ``` Shell completions are installed with it. Upgrade with `brew upgrade frodehus/elevate/elevate-cli`. **Windows, standalone.** `winget install Reothor.Elevate.CLI`, and `winget upgrade Reothor.Elevate.CLI` later. Each release opens its own pull request against `microsoft/winget-pkgs`, so the published version can lag the GitHub release by a day or two; to take a release the moment it lands, download `elevate-cli--win-x64.zip` (or `-win-arm64.zip`) from the [latest release](https://github.com/FrodeHus/elevate/releases/latest) and put `elevate.exe` on your PATH. Do not combine this with the MSI's copy: two `elevate` entries on the PATH means whichever comes first wins. **Any platform.** Download the archive for your platform from the latest release, check it against `elevate-cli--checksums.txt` (`sha256sum -c`), and unpack the single binary anywhere on your PATH. Nothing else is needed: the .NET runtime is inside the file. Archives are named `elevate-cli--.tar.gz` for `linux-x64`, `linux-arm64`, `osx-arm64` and `osx-x64`, and `.zip` for `win-x64` and `win-arm64`. Completions for a shell that Homebrew did not set up: ```bash source <(elevate completion bash) # or zsh; add to your profile elevate completion fish | source elevate completion powershell | Out-String | Invoke-Expression ``` ## Sign in The CLI signs in the same ways the apps do, chosen per account with `--method`: | Method | Command | What it can do | |---|---|---| | Your own app registration | `elevate config set client-id ` then `elevate login` | Entra directory roles, Azure resource roles, PIM for Groups, approvals, access packages | | The shared Elevate app | `elevate config set client-id shared`, `elevate consent` for the link an administrator opens once per tenant, then `elevate login` | The same, once an administrator has consented — optional, no SLA, see [docs/shared-app-registration.md](../docs/shared-app-registration.md) | | An Entra registration of this account's own | `elevate login --method own --client-id ` | The same as your own app registration, but for this account only — it ignores the configured client id | | A custom (company) registration | `elevate login --method custom --client-id ` | The same, given the same permissions; the id is remembered | | The Azure CLI app | `elevate login --method cli` | Azure resource roles only, no registration or consent needed | | The Azure PowerShell app | `elevate login --method pwsh` | Same, for tenants that block the Azure CLI app | Sign-in opens the system browser and returns to `http://localhost`, which the registration must list as a redirect URI under *Mobile and desktop applications* (the setup script and guide in [docs/entra-app-registration.md](../docs/entra-app-registration.md) already add it; the first-party apps accept it without registration; the shared Elevate app lists it too). Over SSH or in a container, pass `--device-code` (or run without a display on Linux, which selects it automatically): the CLI prints a code to enter at microsoft.com/devicelogin from any device. The device code flow requires *Allow public client flows* on your own registration. Microsoft's Azure CLI and Azure PowerShell apps are not pre-authorised for the Graph PIM permissions, so an account added that way sees and activates Azure resource roles only. See the [sign-in methods](../macos/README.md#sign-in-methods) section of the macOS guide for the details; they apply here unchanged. `elevate accounts` names the registration each account uses, a registration of its own by the first characters of its client id. An account moves between registrations later without losing its tenants, roles or profiles: ```bash elevate accounts set-client-id alex 22222222-… # give this account a registration of its own elevate accounts set-client-id alex --from-settings # put it back on the configured one ``` The same command upgrades an Azure CLI, Azure PowerShell or other-app account to an Entra app registration, which adds Entra roles and PIM for Groups. Either way Elevate asks you to sign in with the new registration and saves nothing until the same account comes back; a cancelled sign-in or a different account changes nothing. When your organization manages the client id, an account can only be moved onto the managed registration. Changing the configured id with `elevate config set client-id` keeps the accounts that follow it — their tenants, roles and profiles stay — and asks them to sign in again on next use. Accounts with a registration of their own, and Azure CLI and Azure PowerShell accounts, are unaffected. After the first sign-in the home tenant is tracked. Other tenants: `elevate tenants discover --add` lists and tracks every tenant the account can reach through Azure Resource Manager, and `elevate tenants add contoso.com` tracks one by domain or id. ## Use Every read command re-reads the tenants it needs from the service, so what it prints is current. Roles are named on the command line by their **name** (exact first, then as a substring, case-insensitive) or by the eight-character **id** from `elevate roles`; `--tenant`, `--account`, `--kind entra|azure|groups` and `--scope` narrow the match, and a term that still matches several roles lists them instead of guessing. For Azure resource roles there are two ways to reach a whole slice of the hierarchy, both reading the ARM scope path that the eligibility already carries: - `--under ` takes everything at or below one scope. The value is a resource group or subscription name, a subscription id, or a whole path — `--under prod-rg`, `--under /subscriptions/1111…`. Comparison is step by step, so `/subscriptions/abc` never swallows `/subscriptions/abcdef`. - `--scope` with a `*` in it becomes a glob over the whole path, where `*` crosses slashes: `--scope "/subscriptions/*"` is every eligibility in every subscription, and `--scope "*/resourceGroups/prod-*"` is every resource group whose name starts with `prod-`. Without a `*` it stays the substring search over the path and the scope's caption that it has always been. A management group covers only its own eligibilities, not the subscriptions beneath it: ARM writes a management group scope as its own flat path and never repeats it in a subscription's, so the eligibilities alone cannot say which subscriptions belong to it. Both narrow the candidates, and a name that still matches several roles is still an error. When matching several is the point, `elevate activate --all` takes all of them — `elevate activate Contributor --all --under /subscriptions/1111…` activates Contributor everywhere in that subscription, and with no name at all `--all` takes every eligible role the filters leave. This is the scripted form of the panel's subtree checkbox. | Command | What it does | |---|---| | `elevate` / `elevate status` | What is active, awaiting approval or scheduled, with time left. | | `elevate roles [filter]` | Everything you are eligible for, with policy notes and status. `--active` shows only what is active or pending. | | `elevate watch` | A live countdown table until Ctrl+C; re-reads the service every 60 s (`--interval`). | | `elevate activate ` | Activate. Duration and reason default to what was used last for that role, then to the policy; `--duration 2h`, `--reason`, `--ticket`, `--at 14:30` (or `+2h`) and `--wait` override. `--wait` waits for the role to be *usable*, not just reported active: it waits out provisioning, then checks the access is genuinely in effect. A role that is already active is left alone. With no role named, a checklist is offered in a terminal. `--all` takes every role the filters and names match instead of insisting each name picks exactly one, which is how a script activates a whole subtree with `--under` or a glob `--scope`. | | `elevate extend ` | Deactivate and re-activate, so the clock starts over. Refused for approval-required roles, which would leave you without the role while the request waits. | | `elevate deactivate ` / `elevate cancel ` | Deactivate an active role; withdraw a request that is awaiting approval or scheduled. | | `elevate run [--profile NAME] [--role ROLE…] -- ` | Activate what is named (roles already active are left alone, pending ones are waited for), wait until every one is active, approvals included, then run the command with the terminal's own stdin and stdout and exit with its code. Activations last 10 minutes by default, just enough for one command (`--duration` overrides; the durations remembered for `activate` and the profile are untouched). `--deactivate-after` deactivates what this call activated once the command exits; `--settle 2m` bounds the check that the roles are really in effect before running anyway (`--settle 0` runs as soon as PIM reports them active, keeping the old 30 s pause for groups); `--timeout 1h` bounds the wait for the activations themselves (default 15 m). `--export-token arm` puts a token in the command's own environment as `ELEVATE_ARM_TOKEN`, for a command that cannot call `elevate token` itself. | | `elevate token --resource arm\|graph\|` | Print an access token for that resource on stdout and nothing else, so a command that does its own HTTP can make one authenticated call. It activates nothing: it returns a token for what is active now. `--format json` adds the resource, account, tenant and expiry; `--format kubectl` prints an `ExecCredential` for a kubeconfig exec plugin. `--account` and `--tenant` pick the account and tenant; `--cached` allows a cached token instead of minting a fresh one. `--resource graph` needs `--i-know` (see below). | | `elevate init bash\|zsh\|fish\|pwsh` | A shell hook that wraps `az`, `kubectl`, `terraform` and `helm`: when one fails with an authorization error, a line suggests `elevate run`. `eval "$(elevate init zsh)"` in your profile. | | `elevate profiles` | List profiles. `save ` (or `--from-active`), `show`, `run` (plans first: active and pending entries are skipped; `--dry-run` shows the plan), `rename`, `delete`, `import` (copies the desktop app's profiles), `export ` (prints one of your profiles as a managed profile document). Profiles your organization publishes are listed with source `managed` and cannot be renamed, deleted or saved over. | | `elevate approvals` | Requests awaiting your decision as an approver; `approve ` and `deny --reason …`. Extend and renew requests are listed with "decide in the portal", as in the apps. | | `elevate packages` | Access packages (entitlement management) for accounts signed in with your own or a custom registration: `list` (with the state of any pending request or delivered assignment), `requests` (open ones; `--all` adds denied, failed and cancelled with dates and the service's status), `assigned` (delivered, with expiry and policy), `request --justification …` (`--policy` when several apply; packages that ask questions are handed to My Access with a link), `cancel `. The first call asks for the `EntitlementMgmt-SubjectAccess.ReadWrite` permission, which needs no admin consent. | | `elevate accounts` / `login` / `logout` | The signed-in accounts and the registration each uses; `accounts set-client-id ` moves one to another Entra app registration, keeping its tenants, roles and profiles. | | `elevate tenants` | Tracked tenants with their flags; `discover`, `add`, `remove`, `retry` (clears the manual-roles, azure-off and groups-off latches), and `manual add|list|clear` for tenants that refuse discovery. | | `elevate config` | The client id (`config set client-id shared` selects the shared Elevate app and states its no-SLA caveat once; `config` then shows `shared Elevate app`), the remembered custom client id, the Linux cache mode and the stale-token hint (`config set token-hint off --account alex` hides it for one account); `config path` prints the data directory. | | `elevate consent [--tenant ] [--open]` | The admin consent link for the configured registration: the `organizations` endpoint by default, one tenant with `--tenant` (a tracked tenant's name, or any id or domain). The shared app's link lands on its consent result page, your own registration's on `nativeclient`. `--open` also opens it in the browser. | | `elevate catalogue [query]` | The built-in Entra roles, for `tenants manual add --entra`. | | `elevate diagnostics` | A plain-text report for a bug report. It has no field for a token or client id; it says `Client id: shared Elevate app`, `own registration` or `not set`. | | `elevate update` | Checks GitHub for a newer CLI release. `status` also mentions one, at most once a day. | **Scripts.** `--json` on any command prints stable, camelCase JSON on stdout; tables and progress go to stderr, so `elevate roles --json | jq` is clean. Exit codes: | Code | Meaning | |---|---| | 0 | Done. | | 1 | The operation failed. | | 2 | Bad arguments, or a reason, ticket or client id was needed and not given. | | 3 | No account, or an account needs an interactive sign-in this run could not do. | | 4 | A role, tenant, account, profile or request did not match, or matched several. | | 5 | Some of the requested activations or decisions went through, not all. | | 127 | `run` could not find the command. Otherwise `run` exits with the command's own code. | `activate` is idempotent, so a script can run `elevate activate Reader --tenant prod --reason ci --wait -q` before an `az` command and rely on the exit code; `profiles run` does the same for a set. Prompts appear only when stdin is a terminal and `--json` is off; otherwise the missing value is an error. `elevate run --role Reader --tenant prod -- az group list` folds that into one step: it activates, waits (through an approval too, with a line saying so), then runs the command. Its progress goes to stderr, so the command's stdout can still be piped. Ctrl+C during the wait leaves the activation in place; during the command it reaches the command, and `run` exits with the command's code. **Stale tokens.** After an Azure resource role or a group membership activates, the Azure CLI, Azure PowerShell and kubelogin keep using the token they cached before it, which lacks the new assignment, so the next command is refused as if nothing had happened. The CLI says so after such an activation and names the fix: `az login` again (and `kubelogin remove-tokens` for AKS), or a fresh `Connect-AzAccount`. `elevate config set token-hint off --account alex` hides the line for one account; `on` brings it back. **Tokens for your own tools.** `az`, `terraform`, `kubectl` and `helm` sign themselves in; direct REST calls and in-house tooling do not, and the point of having just activated a role is to make one authenticated request with it. `elevate token` prints one: ```bash elevate run --role Contributor -- sh -c \ 'curl -H "Authorization: Bearer $(elevate token --resource arm)" \ https://management.azure.com/subscriptions?api-version=2022-12-01' ``` A command rather than an exported variable, because a variable is a snapshot: access tokens last 60–90 minutes, and a long-running child handed a stale one gets a `401` with no recourse. Calling the command again gets a fresh token. It is also not inherited by every descendant process, is not readable at `/proc//environ`, and does not land in a crash dump or a stray `env` in a CI log. Elevate never logs the token itself — not in a progress note, not in a failure, not in `diagnostics`. For the genuine cases where the child cannot call back out — a compiled binary, a container entrypoint, a script that is not yours to edit — `elevate run --export-token arm -- ./my-tool` puts it in `ELEVATE_ARM_TOKEN` for that command only, never in your shell. The name always states the resource (`ELEVATE_GRAPH_TOKEN`, `ELEVATE_VAULT_AZURE_NET_TOKEN`), so a token is never pasted at an API it was not minted for. Both are opt-in, and both mint the token after the activation is active, so what was just activated is in it. kubectl can ask for one itself, which solves refresh for free — the plugin is called again when the token expires: ```yaml users: - name: prod-aks user: exec: apiVersion: client.authentication.k8s.io/v1 command: elevate args: ["token", "--resource", "6dae42f8-4368-4678-94ff-3960e28e3630", "--account", "alex@contoso.com"] ``` (`6dae42f8-…` is the AKS AAD server application id.) Terraform's `azurerm` provider takes `ARM_ACCESS_TOKEN` alongside `ARM_SUBSCRIPTION_ID` and `ARM_TENANT_ID`; `az` has no supported way to accept a user access token, so sign it in as usual and use `elevate run`. **What a Graph token can do.** Not much, on purpose. Elevate's registration is consented for `User.Read`, `RoleEligibilitySchedule.Read.Directory`, `RoleAssignmentSchedule.ReadWrite.Directory` and `RoleManagementPolicy.Read.Directory` and nothing else. An activated role does ride in the token's `wids` claim, but that delegated ceiling applies on top of it: having just activated Global Administrator, `--resource graph` will still be refused with a `403` for user management. So `--resource graph` and `--export-token graph` are refused until you add `--i-know`, and say so again on stderr when you do. Widening the scopes to "fix" this would trade away the minimal, auditable consent posture the app registration rests on. ARM is the opposite: `https://management.azure.com/user_impersonation` is the full surface, so an ARM token genuinely carries the Azure role that was just activated. Global options: `--json`, `--quiet`, `--no-color` (or `NO_COLOR`), `--device-code`, `--data-dir`. ## Where things live The CLI keeps its own data directory, separate from the desktop apps on purpose: each app validates the accounts in its state against its own token cache and signs out those it cannot find, so two programs with different caches must not share one `state.json`. | Platform | Default (`ELEVATE_CLI_HOME` or `--data-dir` override) | |---|---| | Windows | `%LOCALAPPDATA%\elevate-cli` | | macOS | `~/Library/Application Support/elevate-cli` | | Linux | `$XDG_CONFIG_HOME/elevate-cli`, else `~/.config/elevate-cli` | It holds `state.json` (accounts, tenants, manual roles, remembered reasons, profiles; the same schema as the apps), `settings.json`, and the MSAL token cache. The cache is protected the way each platform protects secrets: DPAPI on Windows, the login keychain on macOS, the Secret Service keyring (GNOME Keyring, KWallet) on Linux. Nothing is written to disk in plain text unless you say so: on a Linux host without a reachable keyring (SSH, a container), `elevate config set unprotected-cache true` keeps the cache in a plain file under the data directory, readable by anyone with your user's file access; sign in again after changing it. Profiles from the desktop app come across with `elevate profiles import`; entries for accounts not signed in here plan as "tenant not loaded" until you sign those accounts in. ## Managed configuration An organization can push the CLI's settings to a fleet: `/etc/elevate/managed.json` on macOS and Linux (neither the file nor its directory writable by others, or it is ignored with a warning), and `HKLM\SOFTWARE\Policies\Reothor\Elevate` then `HKCU\...` on Windows. A managed value wins over yours, and `elevate config` marks it `managed` in the `Source` column; `elevate config managed` prints the origin, the keys in effect and any warnings, and `--file ` checks a file as a dry run before you deploy it. Administrators start at [docs/enterprise/README.md](../docs/enterprise/README.md), with the CLI's own page at [docs/enterprise/cli.md](../docs/enterprise/cli.md). ## Build and test ```bash cd cli dotnet test Elevate.Cli.sln dotnet run --project src/Elevate.Cli -- --help ``` Needs the .NET 10 SDK (`cli/global.json`), on any of the three platforms. The solution references `../windows/src/Elevate.Core` directly; there is no copy. Tests run with warnings as errors, like the app suites. To produce the single-file binary for a platform: ```bash ./package.sh publish 0.0.0 linux-x64 # dist/linux-x64/elevate ./package.sh archive 0.0.0 linux-x64 # dist/elevate-cli-0.0.0-linux-x64.tar.gz + .sha256 ``` On macOS the release workflow signs the binary with the hardened runtime and `cli/elevate.entitlements` (JIT and unsigned executable memory for the .NET runtime, no library validation); the same file signs the copy bundled in `Elevate.app/Contents/Helpers`. Self-contained single-file publishing cross-compiles across operating systems, so all six RIDs can be produced on one machine; the release workflow still builds each on its own runner so the macOS and Windows binaries can be signed there. The binary is about 34 MB: the framework is trimmed (partial mode, which touches only assemblies that declare themselves trimmable; `Elevate.Core` and the CLI stay whole, so their reflection-based System.Text.Json keeps working) and pre-compiled with ReadyToRun, which is what keeps a command under half a second. Without ReadyToRun the file would be 18 MB and every command would take 1.5 s. Native AOT is not used for the same reason Core cannot be trimmed: it serialises through reflection. Layout: ```text cli/ src/Elevate.Cli/ the binary: Auth (MSAL over the system browser or a device code, the per-platform cache), Session (a headless port of the apps' AppModel), Commands, Selection (role names, ids, durations), Rendering, Completion tests/Elevate.Cli.Tests/ package.sh publish and archive one platform winget/ templates and script for the Reothor.Elevate.CLI portable manifest ``` ## Release Released with the apps: tag `v` on `main` and the [release workflow](../.github/workflows/release.yml) builds the six archives on Linux, macOS and Windows runners, signs the macOS and Windows binaries when the apps' signing secrets are set, attaches them and a checksums file to the same GitHub release as the DMG and the MSIs. The pkg carries the signed osx-arm64 binary and the MSIs carry the signed win-x64/win-arm64 binaries, so the release also ships the CLI inside those installers. The workflow updates `Formula/elevate-cli.rb` on `main` next to the cask with its deprecation notice, and keeps the winget manifest as the `winget-cli-manifest` artifact. The full procedure is in [docs/releasing.md](../docs/releasing.md).