Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Windows and CLI: **the Azure tab is a scope tree, and a subtree is one click.** Azure resource
eligibility does not scale in a flat list — a platform engineer eligible for Contributor on sixty
subscriptions had sixty sibling rows to read and sixty clicks before the single activation Elevate
promised. The Windows **Azure** pivot now groups its rows by the hierarchy the ARM scope string
already describes: management group, subscription, resource group, resource, each header opening
and closing and saying how many roles sit under it. A scope that only passes through — no
eligibility of its own, one way down — is folded into the node below and named there
("Alpha / prod"), so a narrow panel spends no row or indent on nothing. In select mode a scope
header carries its own checkbox that takes every eligibility under it at once. The search box
narrows the tree rather than sitting beside it, now reaches the whole ARM path, and keeps matches
in their place in the tree even under a header you had closed. For scripts, `elevate` gains
`--under <scope>` (everything at or below a resource group or subscription name, a subscription
id, or a whole path, compared step by step so `/subscriptions/abc` never swallows
`/subscriptions/abcdef`) and a glob form of `--scope` where a `*` crosses slashes
(`--scope "/subscriptions/*"`); `--scope` without a `*` is the substring search it always was.
`elevate activate --all` then takes every role the filters and names match instead of insisting
each name picks exactly one, which is the scripted form of the subtree checkbox. Management
groups sit beside the subscriptions rather than above them, in the panel and for `--under`: 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 which management group. The
macOS Azure tab still lists flat; its tree follows.
([#186](https://github.com/FrodeHus/elevate/issues/186))
- macOS and Windows: the activation sheet no longer signs off with "Active", the word that means
only that PIM wrote the assignment down. It holds for the first effective-access check and closes
on **Ready** when the access is already there, on **Activated** when the check has not answered in
Expand Down
25 changes: 24 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,35 @@ case-insensitive) or by the eight-character **id** from `elevate roles`; `--tena
`--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 <scope>` 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 <role…>` | 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. |
| `elevate activate <role…>` | 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 <role…>` | 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 <role…>` / `elevate cancel <role…>` | Deactivate an active role; withdraw a request that is awaiting approval or scheduled. |
| `elevate run [--profile NAME] [--role ROLE…] -- <command>` | 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. |
Expand Down
39 changes: 29 additions & 10 deletions cli/src/Elevate.Cli/Commands/ActivationCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,26 @@ public static Command Activate()
var tenant = CommonOptions.Tenant();
var kind = CommonOptions.Kind();
var scope = CommonOptions.Scope();
var under = CommonOptions.Under();
var duration = new Option<string?>("--duration", "-d") { Description = "How long, e.g. 2h, 30m, 1h30m. Default: the last duration used, else the policy default." };
var reason = new Option<string?>("--reason", "-r") { Description = "Justification. Default: the reason remembered for the role; prompted when required and missing." };
var ticket = new Option<string?>("--ticket") { Description = "Ticket number, when the policy asks for one." };
var ticketSystem = new Option<string?>("--ticket-system") { Description = "Ticket system name to go with --ticket." };
var at = new Option<string?>("--at") { Description = "Start later: +2h, 14:30 or 2026-09-08T09:00." };
var wait = new Option<bool>("--wait") { Description = "Wait until the role is usable, not just reported active: provisioning first, then a check that the access is really in effect." };
var all = new Option<bool>("--all") { Description = "Take every role the filters and names match, instead of insisting each name picks exactly one. With no name, every eligible role the filters leave." };
var command = new Command("activate", "Activate one or more roles. Roles that are already active are left alone; use 'extend' for those.")
{
roles, account, tenant, kind, scope, duration, reason, ticket, ticketSystem, at, wait,
roles, account, tenant, kind, scope, under, duration, reason, ticket, ticketSystem, at, wait, all,
};
command.SetAction(async (parse, ct) =>
{
var context = CommandContext.From(parse);
context.RequireSignedIn();
var session = await context.SessionAsync(ct).ConfigureAwait(false);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope, under);
await RoleCommands.RefreshAsync(context, filter, ct).ConfigureAwait(false);
var chosen = await ChooseAsync(context, parse.GetValue(roles) ?? [], filter, "activate").ConfigureAwait(false);
var chosen = await ChooseAsync(context, parse.GetValue(roles) ?? [], filter, "activate", parse.GetValue(all)).ConfigureAwait(false);
var wanted = parse.GetValue(duration) is { } d ? DurationParser.Require(d) : (TimeSpan?)null;
var start = parse.GetValue(at) is { } a ? StartTimeParser.Require(a) : (DateTimeOffset?)null;
var ticketInfo = TicketFrom(parse.GetValue(ticket), parse.GetValue(ticketSystem));
Expand Down Expand Up @@ -91,19 +93,20 @@ public static Command Extend()
var tenant = CommonOptions.Tenant();
var kind = CommonOptions.Kind();
var scope = CommonOptions.Scope();
var under = CommonOptions.Under();
var duration = new Option<string?>("--duration", "-d") { Description = "How long from now, e.g. 2h. Default: the last duration used, else the policy default." };
var reason = new Option<string?>("--reason", "-r") { Description = "Justification. Default: the remembered reason." };
var wait = new Option<bool>("--wait") { Description = "Wait until the role is active again and the access is really in effect." };
var command = new Command("extend", "Deactivate and re-activate an active role, so the clock starts over.")
{
roles, account, tenant, kind, scope, duration, reason, wait,
roles, account, tenant, kind, scope, under, duration, reason, wait,
};
command.SetAction(async (parse, ct) =>
{
var context = CommandContext.From(parse);
context.RequireSignedIn();
var session = await context.SessionAsync(ct).ConfigureAwait(false);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope, under);
await RoleCommands.RefreshAsync(context, filter, ct).ConfigureAwait(false);
var chosen = RoleSelector.Resolve(session, parse.GetValue(roles) ?? [], filter);
var wanted = parse.GetValue(duration) is { } d ? DurationParser.Require(d) : (TimeSpan?)null;
Expand Down Expand Up @@ -135,13 +138,14 @@ public static Command Deactivate()
var tenant = CommonOptions.Tenant();
var kind = CommonOptions.Kind();
var scope = CommonOptions.Scope();
var command = new Command("deactivate", "Deactivate active roles.") { roles, account, tenant, kind, scope };
var under = CommonOptions.Under();
var command = new Command("deactivate", "Deactivate active roles.") { roles, account, tenant, kind, scope, under };
command.SetAction(async (parse, ct) =>
{
var context = CommandContext.From(parse);
context.RequireSignedIn();
var session = await context.SessionAsync(ct).ConfigureAwait(false);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope, under);
await RoleCommands.RefreshAsync(context, filter, ct).ConfigureAwait(false);
var chosen = RoleSelector.Resolve(session, parse.GetValue(roles) ?? [], filter);
var failures = 0;
Expand Down Expand Up @@ -188,13 +192,14 @@ public static Command Cancel()
var tenant = CommonOptions.Tenant();
var kind = CommonOptions.Kind();
var scope = CommonOptions.Scope();
var command = new Command("cancel", "Withdraw a request that is awaiting approval or scheduled for later.") { roles, account, tenant, kind, scope };
var under = CommonOptions.Under();
var command = new Command("cancel", "Withdraw a request that is awaiting approval or scheduled for later.") { roles, account, tenant, kind, scope, under };
command.SetAction(async (parse, ct) =>
{
var context = CommandContext.From(parse);
context.RequireSignedIn();
var session = await context.SessionAsync(ct).ConfigureAwait(false);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope, under);
await RoleCommands.RefreshAsync(context, filter, ct).ConfigureAwait(false);
var chosen = RoleSelector.Resolve(session, parse.GetValue(roles) ?? [], filter);
var failures = 0;
Expand Down Expand Up @@ -224,9 +229,23 @@ public static Command Cancel()
}

/// <summary>Resolves the typed roles, or offers a checklist when none were typed and a person is there to pick.</summary>
private static Task<IReadOnlyList<EligibleRole>> ChooseAsync(CommandContext context, string[] terms, RoleFilter filter, string verb)
private static Task<IReadOnlyList<EligibleRole>> ChooseAsync(
CommandContext context, string[] terms, RoleFilter filter, string verb, bool all = false)
{
var session = context.Session;
if (all)
{
// The scripted form of the panel's subtree checkbox: the filters say what to take, and
// a name that matches twelve subscriptions is the point rather than an error.
var matched = RoleSelector.ResolveAll(session, terms, filter);
if (matched.Count == 0)
{
throw new CliException("No eligible role matches those filters. Run 'elevate roles' to see what is available.", ExitCodes.NotFound);
}

return Task.FromResult(matched);
}

if (terms.Length > 0)
{
return Task.FromResult(RoleSelector.Resolve(session, terms, filter));
Expand Down
24 changes: 21 additions & 3 deletions cli/src/Elevate.Cli/Commands/CommonOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,35 @@ public static class CommonOptions

public static Option<string?> Kind() => new("--kind", "-k") { Description = "Only this kind: entra, azure or groups." };

public static Option<string?> Scope() => new("--scope", "-s") { Description = "Only Azure roles whose scope contains this text (subscription name, resource group…)." };
public static Option<string?> Scope() => new("--scope", "-s")
{
Description = "Only Azure roles whose scope contains this text (subscription name, resource group…). "
+ "A value with a * is a glob over the whole scope path, where * crosses slashes: \"/subscriptions/*\".",
};

public static Option<string?> Under() => new("--under")
{
Description = "Only Azure roles at or below this scope: a resource group or subscription name, a subscription id, "
+ "or a whole scope path. Management groups cover only their own eligibilities — ARM does not record which "
+ "subscriptions sit under them.",
};

public static Option<bool> Yes() => new("--yes", "-y") { Description = "Do not ask for confirmation." };

public static RoleFilter Filter(ParseResult parse, Option<string?> account, Option<string?> tenant, Option<string?>? kind = null, Option<string?>? scope = null)
public static RoleFilter Filter(
ParseResult parse,
Option<string?> account,
Option<string?> tenant,
Option<string?>? kind = null,
Option<string?>? scope = null,
Option<string?>? under = null)
{
ArgumentNullException.ThrowIfNull(parse);
return new RoleFilter(
parse.GetValue(account),
parse.GetValue(tenant),
kind is null ? null : RoleFilter.ParseKind(parse.GetValue(kind)),
scope is null ? null : parse.GetValue(scope));
scope is null ? null : parse.GetValue(scope),
under is null ? null : parse.GetValue(under));
}
}
5 changes: 3 additions & 2 deletions cli/src/Elevate.Cli/Commands/ProfileCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,18 @@ private static Command Save()
var tenant = CommonOptions.Tenant();
var kind = CommonOptions.Kind();
var scope = CommonOptions.Scope();
var under = CommonOptions.Under();
var fromActive = new Option<bool>("--from-active") { Description = "Use everything that is active right now instead of naming roles." };
var update = new Option<bool>("--update") { Description = "Replace the roles of an existing profile with this name." };
var command = new Command("save", "Save a set of roles as a profile.") { name, roles, account, tenant, kind, scope, fromActive, update };
var command = new Command("save", "Save a set of roles as a profile.") { name, roles, account, tenant, kind, scope, under, fromActive, update };
command.SetAction(async (parse, ct) =>
{
var context = CommandContext.From(parse);
context.RequireSignedIn();
var session = await context.SessionAsync(ct).ConfigureAwait(false);
// Refused before the roles are read: a name the organization publishes is never saved over.
session.RefuseIfManagedName(parse.GetValue(name)!);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope);
var filter = CommonOptions.Filter(parse, account, tenant, kind, scope, under);
await RoleCommands.RefreshAsync(context, filter, ct).ConfigureAwait(false);
var terms = parse.GetValue(roles) ?? [];
IReadOnlyList<RoleKey> keys;
Expand Down
5 changes: 3 additions & 2 deletions cli/src/Elevate.Cli/Commands/RoleCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,19 @@ public static Command Roles()
var tenant = CommonOptions.Tenant();
var kind = CommonOptions.Kind();
var scope = CommonOptions.Scope();
var under = CommonOptions.Under();
var active = new Option<bool>("--active") { Description = "Only roles that are active, pending or scheduled." };
var filter = new Argument<string?>("filter") { Description = "Only roles whose name contains this text.", Arity = ArgumentArity.ZeroOrOne };
var command = new Command("roles", "List the roles and groups you are eligible for, with their status.")
{
filter, account, tenant, kind, scope, active,
filter, account, tenant, kind, scope, under, active,
};
command.SetAction(async (parse, ct) =>
{
var context = CommandContext.From(parse);
context.RequireSignedIn();
var session = await context.SessionAsync(ct).ConfigureAwait(false);
var roleFilter = CommonOptions.Filter(parse, account, tenant, kind, scope);
var roleFilter = CommonOptions.Filter(parse, account, tenant, kind, scope, under);
await RefreshAsync(context, roleFilter, ct).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var text = parse.GetValue(filter);
Expand Down
Loading
Loading