From dd07e729e71825ea90da76e0b27d7c1cbb1c87a6 Mon Sep 17 00:00:00 2001 From: Frode Hus Date: Sat, 19 Sep 2026 01:12:04 +0200 Subject: [PATCH] Group the Azure tab by scope, and let one click take a subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flat list does not scale: someone eligible for Contributor on sixty subscriptions reads sixty sibling rows and clicks sixty times before the single activation Elevate promised them. The scope string already carries the hierarchy, so nothing extra has to be fetched to show it. ArmScope reads an ARM path as the steps it is, and ScopeTree builds the management group / subscription / resource group / resource tree from a tenant's eligibilities and flattens it back into rows. Two shapes keep the tree from costing more rows than it saves: a scope that only passes through is folded into the node below it and named there ("Alpha / prod"), and a scope leading to a single role gets no header at all. The Windows Azure pivot draws that tree, each header opening and closing and saying how many roles sit under it, with a three-state checkbox in select mode that takes the whole subtree at once. Search narrows the tree rather than sitting beside it, reaches the whole ARM path, and keeps matches in place even under a header that was closed. For scripts, --under takes everything at or below a scope, compared step by step so /subscriptions/abc never swallows /subscriptions/abcdef, and --scope becomes a glob when it carries a *. Neither is enough on its own, because a name matching twelve roles is still an error, so activate --all takes every match instead of insisting on one. 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. That leaves PanelFilter diverged for now — the C# copy searches the ARM path and the Swift one does not. Refs #186 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 22 ++ cli/README.md | 25 +- .../Commands/ActivationCommands.cs | 39 ++- cli/src/Elevate.Cli/Commands/CommonOptions.cs | 24 +- .../Elevate.Cli/Commands/ProfileCommands.cs | 5 +- cli/src/Elevate.Cli/Commands/RoleCommands.cs | 5 +- cli/src/Elevate.Cli/Commands/RunCommands.cs | 5 +- cli/src/Elevate.Cli/Selection/RoleSelector.cs | 91 ++++++- .../Elevate.Cli.Tests/ScopeFilterTests.cs | 142 +++++++++++ windows/README.md | 13 + .../ViewModels/AppModel.Panel.cs | 118 +++++++++ .../Elevate.App.Model/ViewModels/AppModel.cs | 6 + windows/src/Elevate.App/Views/PanelItems.cs | 185 ++++++++++++++ windows/src/Elevate.App/Views/PanelView.xaml | 40 +++- .../src/Elevate.App/Views/PanelView.xaml.cs | 49 ++++ windows/src/Elevate.Core/Support/ArmScope.cs | 218 +++++++++++++++++ .../src/Elevate.Core/Support/PanelFilter.cs | 5 +- windows/src/Elevate.Core/Support/ScopeTree.cs | 225 ++++++++++++++++++ .../AppModelScopeTreeTests.cs | 200 ++++++++++++++++ .../tests/Elevate.Core.Tests/ArmScopeTests.cs | 142 +++++++++++ .../Elevate.Core.Tests/ScopeTreeTests.cs | 202 ++++++++++++++++ 21 files changed, 1730 insertions(+), 31 deletions(-) create mode 100644 cli/tests/Elevate.Cli.Tests/ScopeFilterTests.cs create mode 100644 windows/src/Elevate.Core/Support/ArmScope.cs create mode 100644 windows/src/Elevate.Core/Support/ScopeTree.cs create mode 100644 windows/tests/Elevate.App.Tests/AppModelScopeTreeTests.cs create mode 100644 windows/tests/Elevate.Core.Tests/ArmScopeTests.cs create mode 100644 windows/tests/Elevate.Core.Tests/ScopeTreeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f2166164..a13875ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` (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 diff --git a/cli/README.md b/cli/README.md index 1c0b80cb..ff9cb8cb 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 ` 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. | +| `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. | diff --git a/cli/src/Elevate.Cli/Commands/ActivationCommands.cs b/cli/src/Elevate.Cli/Commands/ActivationCommands.cs index b49abdbf..c1ca4742 100644 --- a/cli/src/Elevate.Cli/Commands/ActivationCommands.cs +++ b/cli/src/Elevate.Cli/Commands/ActivationCommands.cs @@ -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("--duration", "-d") { Description = "How long, e.g. 2h, 30m, 1h30m. Default: the last duration used, else the policy default." }; var reason = new Option("--reason", "-r") { Description = "Justification. Default: the reason remembered for the role; prompted when required and missing." }; var ticket = new Option("--ticket") { Description = "Ticket number, when the policy asks for one." }; var ticketSystem = new Option("--ticket-system") { Description = "Ticket system name to go with --ticket." }; var at = new Option("--at") { Description = "Start later: +2h, 14:30 or 2026-09-08T09:00." }; var wait = new Option("--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("--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)); @@ -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("--duration", "-d") { Description = "How long from now, e.g. 2h. Default: the last duration used, else the policy default." }; var reason = new Option("--reason", "-r") { Description = "Justification. Default: the remembered reason." }; var wait = new Option("--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; @@ -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; @@ -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; @@ -224,9 +229,23 @@ public static Command Cancel() } /// Resolves the typed roles, or offers a checklist when none were typed and a person is there to pick. - private static Task> ChooseAsync(CommandContext context, string[] terms, RoleFilter filter, string verb) + private static Task> 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)); diff --git a/cli/src/Elevate.Cli/Commands/CommonOptions.cs b/cli/src/Elevate.Cli/Commands/CommonOptions.cs index 99a7cbf8..3d6731ad 100644 --- a/cli/src/Elevate.Cli/Commands/CommonOptions.cs +++ b/cli/src/Elevate.Cli/Commands/CommonOptions.cs @@ -12,17 +12,35 @@ public static class CommonOptions public static Option Kind() => new("--kind", "-k") { Description = "Only this kind: entra, azure or groups." }; - public static Option Scope() => new("--scope", "-s") { Description = "Only Azure roles whose scope contains this text (subscription name, resource group…)." }; + public static Option 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 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 Yes() => new("--yes", "-y") { Description = "Do not ask for confirmation." }; - public static RoleFilter Filter(ParseResult parse, Option account, Option tenant, Option? kind = null, Option? scope = null) + public static RoleFilter Filter( + ParseResult parse, + Option account, + Option tenant, + Option? kind = null, + Option? scope = null, + Option? 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)); } } diff --git a/cli/src/Elevate.Cli/Commands/ProfileCommands.cs b/cli/src/Elevate.Cli/Commands/ProfileCommands.cs index e77ed247..2f9fc1f6 100644 --- a/cli/src/Elevate.Cli/Commands/ProfileCommands.cs +++ b/cli/src/Elevate.Cli/Commands/ProfileCommands.cs @@ -109,9 +109,10 @@ private static Command Save() var tenant = CommonOptions.Tenant(); var kind = CommonOptions.Kind(); var scope = CommonOptions.Scope(); + var under = CommonOptions.Under(); var fromActive = new Option("--from-active") { Description = "Use everything that is active right now instead of naming roles." }; var update = new Option("--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); @@ -119,7 +120,7 @@ private static Command Save() 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 keys; diff --git a/cli/src/Elevate.Cli/Commands/RoleCommands.cs b/cli/src/Elevate.Cli/Commands/RoleCommands.cs index 80b16e62..5e8a22ee 100644 --- a/cli/src/Elevate.Cli/Commands/RoleCommands.cs +++ b/cli/src/Elevate.Cli/Commands/RoleCommands.cs @@ -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("--active") { Description = "Only roles that are active, pending or scheduled." }; var filter = new Argument("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); diff --git a/cli/src/Elevate.Cli/Commands/RunCommands.cs b/cli/src/Elevate.Cli/Commands/RunCommands.cs index 3fdf80e3..668e607c 100644 --- a/cli/src/Elevate.Cli/Commands/RunCommands.cs +++ b/cli/src/Elevate.Cli/Commands/RunCommands.cs @@ -40,6 +40,7 @@ public static Command Run() var tenant = CommonOptions.Tenant(); var kind = CommonOptions.Kind(); var scope = CommonOptions.Scope(); + var under = CommonOptions.Under(); var duration = new Option("--duration", "-d") { Description = "How long the roles stay active, e.g. 30m. Default: 10m (or the policy maximum when lower), just enough for one command; the durations remembered for 'activate' and the profile are left alone." }; var reason = new Option("--reason", "-r") { Description = "Justification. Default: the remembered reason; prompted when required and missing." }; var ticket = new Option("--ticket") { Description = "Ticket number, when a policy asks for one." }; @@ -51,7 +52,7 @@ public static Command Run() var iKnow = new Option("--i-know") { Description = "Acknowledge the Graph scope ceiling, which --export-token graph needs." }; var run = new Command("run", "Activate roles or a profile, wait until they are active, then run a command and exit with its code.") { - profile, roles, command, account, tenant, kind, scope, duration, reason, ticket, ticketSystem, deactivateAfter, settle, timeout, exportToken, iKnow, + profile, roles, command, account, tenant, kind, scope, under, duration, reason, ticket, ticketSystem, deactivateAfter, settle, timeout, exportToken, iKnow, }; run.SetAction(async (parse, ct) => { @@ -91,7 +92,7 @@ public static Command Run() 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); var chosenProfile = profileName is null ? null : session.FindProfile(profileName) ?? throw new CliException($"No profile matches '{profileName}'. 'elevate profiles' lists them.", ExitCodes.NotFound); diff --git a/cli/src/Elevate.Cli/Selection/RoleSelector.cs b/cli/src/Elevate.Cli/Selection/RoleSelector.cs index 1d8c27d8..9924e78d 100644 --- a/cli/src/Elevate.Cli/Selection/RoleSelector.cs +++ b/cli/src/Elevate.Cli/Selection/RoleSelector.cs @@ -1,11 +1,17 @@ using Elevate.Cli.Infrastructure; using Elevate.Cli.Session; using Elevate.Core.Models; +using Elevate.Core.Support; namespace Elevate.Cli.Selection; /// Filters shared by every command that names roles: account, tenant, kind and scope. -public sealed record RoleFilter(string? Account = null, string? Tenant = null, RoleScopeKind? Kind = null, string? Scope = null) +public sealed record RoleFilter( + string? Account = null, + string? Tenant = null, + RoleScopeKind? Kind = null, + string? Scope = null, + string? Under = null) { public static RoleScopeKind? ParseKind(string? text) => (text ?? string.Empty).Trim().ToLowerInvariant() switch { @@ -64,18 +70,56 @@ public static bool RoleMatches(ElevateSession session, EligibleRole role, RoleFi return false; } - if (filter.Scope is { Length: > 0 } scope) + if (filter.Scope is { Length: > 0 } scope && !ScopeMatches(role, scope)) { - var text = role.Key.Scope is AzureResourceScope azure ? azure.Scope + " " + (role.Detail ?? string.Empty) : role.Detail ?? string.Empty; - if (!Contains(text, scope)) - { - return false; - } + return false; + } + + // --under reads the path as the hierarchy it is, so one term reaches a whole subtree. + if (filter.Under is { Length: > 0 } under && !UnderMatches(role, under)) + { + return false; } return session.Tenant(role.Key.TenantKey) is { } tenant && TenantMatches(session, tenant, filter); } + /// + /// --scope: plain text is a substring of the path and the caption, as it has always + /// been; a value carrying a * is a glob matched whole against the path, the caption and + /// the leaf's name, so --scope "/subscriptions/*" reaches everything in every subscription. + /// + private static bool ScopeMatches(EligibleRole role, string scope) + { + var path = role.Key.Scope is AzureResourceScope azure ? azure.Scope : null; + if (!ArmScope.LooksLikePattern(scope)) + { + var text = path is null ? role.Detail ?? string.Empty : path + " " + (role.Detail ?? string.Empty); + return Contains(text, scope); + } + + var leaf = path is null ? null : ArmScope.Segments(path).LastOrDefault()?.Name; + return ArmScope.MatchesPattern(scope, path) + || ArmScope.MatchesPattern(scope, role.Detail) + || (leaf is not null && ArmScope.MatchesPattern(scope, leaf)); + } + + /// + /// --under: only Azure roles, and only those at or below the named scope. The term is a + /// whole scope path, or the name or id of one step of it. + /// + private static bool UnderMatches(EligibleRole role, string under) + { + if (role.Key.Scope is not AzureResourceScope azure) + { + return false; + } + + return under.StartsWith('/') + ? ArmScope.IsAtOrUnder(azure.Scope, under) + : ArmScope.HasSegmentNamed(azure.Scope, under); + } + public static IReadOnlyList Filtered(ElevateSession session, RoleFilter filter) { ArgumentNullException.ThrowIfNull(session); @@ -107,7 +151,7 @@ public static IReadOnlyList Resolve(ElevateSession session, IEnume { var lines = matches.Select(m => $" {ShortId.For(m.Key)} {m.DisplayName} ({Describe(session, m)})"); throw new CliException( - $"'{term}' matches {matches.Count} roles; use the id, or narrow with --tenant, --account, --kind or --scope:\n" + string.Join('\n', lines), + $"'{term}' matches {matches.Count} roles; use the id, narrow with --tenant, --account, --kind, --scope or --under, or take them all with --all:\n" + string.Join('\n', lines), ExitCodes.NotFound); } @@ -120,6 +164,37 @@ public static IReadOnlyList Resolve(ElevateSession session, IEnume return chosen; } + /// + /// Every role each term matches, rather than insisting on one. What --all asks for: with + /// no terms it is the whole filtered list, so --under or a glob --scope selects a + /// subtree the way the panel's checkbox does. + /// + public static IReadOnlyList ResolveAll(ElevateSession session, IEnumerable terms, RoleFilter filter) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(terms); + var candidates = Filtered(session, filter); + var wanted = terms.Select(t => t.Trim()).Where(t => t.Length > 0).ToList(); + if (wanted.Count == 0) + { + return candidates; + } + + var chosen = new List(); + foreach (var term in wanted) + { + var matches = Matches(candidates, term); + if (matches.Count == 0) + { + throw new CliException($"No eligible role matches '{term}'. Run 'elevate roles' to see what is available.", ExitCodes.NotFound); + } + + chosen.AddRange(matches.Where(m => !chosen.Contains(m))); + } + + return chosen; + } + private static List Matches(IReadOnlyList candidates, string term) { if (ShortId.LooksLikeId(term)) diff --git a/cli/tests/Elevate.Cli.Tests/ScopeFilterTests.cs b/cli/tests/Elevate.Cli.Tests/ScopeFilterTests.cs new file mode 100644 index 00000000..fa1c57bf --- /dev/null +++ b/cli/tests/Elevate.Cli.Tests/ScopeFilterTests.cs @@ -0,0 +1,142 @@ +using Elevate.Cli.Infrastructure; +using Elevate.Cli.Selection; +using Elevate.Cli.Tests.Support; +using Elevate.Core.Models; +using FluentAssertions; + +namespace Elevate.Cli.Tests; + +/// +/// The scripted half of the Azure scope hierarchy: --under reaches a subtree, a glob +/// --scope reaches a pattern, and --all lets one term take everything it matched. +/// +public class ScopeFilterTests +{ + private const string SubA = "/subscriptions/aaaaaaaa-0000-0000-0000-000000000000"; + private const string SubB = "/subscriptions/bbbbbbbb-0000-0000-0000-000000000000"; + private const string Mg = "/providers/Microsoft.Management/managementGroups/platform"; + + private static RoleKey AzureKey(string scope, string roleDefinitionId) => + new("id1", "t1", new AzureResourceScope(scope, roleDefinitionId)); + + private static EligibleRole Azure(string scope, string name, string? detail = null) => + new(AzureKey(scope, "rd-" + name + "-" + scope), name, RoleSource.Discovered, RolePolicy.ManualDefault, Detail: detail); + + /// A tenant eligible for Contributor across two subscriptions, plus a few neighbours. + private static async Task SeedAsync() + { + var t = new TestSession(); + t.Azure.Eligible.Add(Azure(SubA, "Contributor", "Alpha · subscription")); + t.Azure.Eligible.Add(Azure(SubB, "Contributor", "Bravo · subscription")); + t.Azure.Eligible.Add(Azure(SubA + "/resourceGroups/prod-rg", "Owner", "prod-rg · resource group")); + t.Azure.Eligible.Add(Azure(SubA + "/resourceGroups/test-rg", "Reader", "test-rg · resource group")); + t.Azure.Eligible.Add(Azure(Mg, "Reader", "Platform · management group")); + t.Entra.Eligible.Add(TestSession.Role("r1", "Global Reader")); + await t.Session.RefreshAllAsync(); + return t; + } + + [Fact] + public async Task UnderTakesEverythingAtOrBelowASubscription() + { + using var t = await SeedAsync(); + RoleSelector.Filtered(t.Session, new RoleFilter(Under: SubA)) + .Select(r => r.DisplayName).Should().BeEquivalentTo("Contributor", "Owner", "Reader"); + } + + [Fact] + public async Task UnderAcceptsABareResourceGroupName() + { + using var t = await SeedAsync(); + RoleSelector.Filtered(t.Session, new RoleFilter(Under: "prod-rg")) + .Should().ContainSingle().Which.DisplayName.Should().Be("Owner"); + } + + [Fact] + public async Task UnderAcceptsASubscriptionIdOnItsOwn() + { + using var t = await SeedAsync(); + RoleSelector.Filtered(t.Session, new RoleFilter(Under: "aaaaaaaa-0000-0000-0000-000000000000")).Should().HaveCount(3); + } + + [Fact] + public async Task UnderNeverMatchesANonAzureRole() + { + using var t = await SeedAsync(); + RoleSelector.Filtered(t.Session, new RoleFilter(Under: "/")) + .Should().OnlyContain(r => r.Key.Scope.Kind == RoleScopeKind.AzureResource); + } + + [Fact] + public async Task UnderAManagementGroupCoversOnlyItsOwnEligibilities() + { + // ARM does not record which subscriptions sit under a management group, so the scope + // string cannot reach them. The option's help says so. + using var t = await SeedAsync(); + RoleSelector.Filtered(t.Session, new RoleFilter(Under: "platform")) + .Should().ContainSingle().Which.Key.Scope.Should().Be(new AzureResourceScope(Mg, "rd-Reader-" + Mg)); + } + + [Fact] + public async Task AGlobScopeReachesEverythingInEverySubscription() + { + using var t = await SeedAsync(); + RoleSelector.Filtered(t.Session, new RoleFilter(Scope: "/subscriptions/*")).Should().HaveCount(4); + } + + [Fact] + public async Task AGlobScopeCanPickOutResourceGroupsByName() + { + using var t = await SeedAsync(); + RoleSelector.Filtered(t.Session, new RoleFilter(Scope: "*/resourceGroups/*-rg")) + .Select(r => r.DisplayName).Should().BeEquivalentTo("Owner", "Reader"); + } + + [Fact] + public async Task PlainScopeTextStaysASubstringSearch() + { + using var t = await SeedAsync(); + RoleSelector.Filtered(t.Session, new RoleFilter(Scope: "prod")).Should().ContainSingle(); + RoleSelector.Filtered(t.Session, new RoleFilter(Scope: "Alpha")).Should().ContainSingle(); + } + + [Fact] + public async Task ResolveStillInsistsOnOneRolePerName() + { + using var t = await SeedAsync(); + var ambiguous = () => RoleSelector.Resolve(t.Session, ["Contributor"], new RoleFilter()); + ambiguous.Should().Throw().Which.Message.Should().Contain("--all"); + } + + [Fact] + public async Task ResolveAllTakesEveryRoleANameMatches() + { + using var t = await SeedAsync(); + RoleSelector.ResolveAll(t.Session, ["Contributor"], new RoleFilter()).Should().HaveCount(2); + } + + [Fact] + public async Task ResolveAllWithoutANameIsTheWholeFilteredList() + { + using var t = await SeedAsync(); + RoleSelector.ResolveAll(t.Session, [], new RoleFilter(Under: SubA)).Should().HaveCount(3); + } + + [Fact] + public async Task ResolveAllStillFailsOnANameThatMatchesNothing() + { + using var t = await SeedAsync(); + var missing = () => RoleSelector.ResolveAll(t.Session, ["Nonexistent"], new RoleFilter()); + missing.Should().Throw().Which.ExitCode.Should().Be(ExitCodes.NotFound); + } + + [Fact] + public async Task UnderAndANameNarrowTogether() + { + // "Contributor across this subscription" — the shape the panel's subtree checkbox makes. + using var t = await SeedAsync(); + RoleSelector.ResolveAll(t.Session, ["Contributor"], new RoleFilter(Under: SubA)) + .Should().ContainSingle().Which.Key.Scope.Should().BeOfType() + .Which.Scope.Should().Be(SubA); + } +} diff --git a/windows/README.md b/windows/README.md index 260cccf3..7c43f30c 100644 --- a/windows/README.md +++ b/windows/README.md @@ -75,6 +75,19 @@ one profile the same way. Requests waiting for *your* approval appear in a pinne group above *Active now* with Approve and Deny; a toast announces each new request once, and the tray icon carries an orange dot while any are pending. +The **Azure** pivot groups its eligibilities by scope instead of listing them flat: management +groups, subscriptions, resource groups and resources, each header opening and closing with its +chevron and saying how many roles sit under it. A scope that only passes through — no eligibility +of its own and one way down — is folded into the node below it and named there ("Alpha / prod"), +so the panel does not spend a row and a level of indent on nothing. In select mode a scope header +carries its own checkbox: one press takes every eligibility under it, which is what +"Contributor across these twelve subscriptions" costs now. The search box narrows the tree and +reaches the whole ARM path, and matches stay in place even under a header you had closed. + +Management groups sit beside the subscriptions rather than above them: 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. + Settings also holds *Start Elevate when I sign in* (a per-user Run entry), *Check for updates* (the flyout offers a newer release with a Windows installer once a day, with Open and Dismiss) and *Copy diagnostics* (a plain-text report of accounts, tenants, profiles, the shortcut and recent errors, diff --git a/windows/src/Elevate.App.Model/ViewModels/AppModel.Panel.cs b/windows/src/Elevate.App.Model/ViewModels/AppModel.Panel.cs index bc1151c7..a1a7253a 100644 --- a/windows/src/Elevate.App.Model/ViewModels/AppModel.Panel.cs +++ b/windows/src/Elevate.App.Model/ViewModels/AppModel.Panel.cs @@ -70,6 +70,41 @@ public void ToggleIdentity(string id) Touch(); } + // MARK: The Azure pivot's scope tree + + /// + /// The Azure pivot's eligibilities for one tenant, as the management group / subscription / + /// resource group / resource tree the scope strings already describe. Built from the filtered + /// rows, so a search narrows the tree rather than sitting beside it. + /// + public IReadOnlyList AzureTree(TenantKey tenantKey) => + ScopeTree.Build(RolesFor(tenantKey, PanelTab.Azure)); + + /// A scope node's collapsed state is per tenant: the same scope can be reached by two accounts. + public static string ScopeNodeKey(TenantKey tenantKey, ScopeNode node) + { + ArgumentNullException.ThrowIfNull(node); + return $"{tenantKey.IdentityId}|{tenantKey.TenantId}|{node.Scope}"; + } + + /// + /// While a search is running every match stays visible in its place in the tree, so a closed + /// node does not hide a row the user is looking at. + /// + public bool IsScopeCollapsed(TenantKey tenantKey, ScopeNode node) => + !IsFiltering && CollapsedScopes.Contains(ScopeNodeKey(tenantKey, node)); + + public void ToggleScope(TenantKey tenantKey, ScopeNode node) + { + var key = ScopeNodeKey(tenantKey, node); + if (!CollapsedScopes.Remove(key)) + { + CollapsedScopes.Add(key); + } + + Touch(); + } + // MARK: Search public bool IsFiltering => PanelFilter.IsActive(SearchQuery); @@ -220,6 +255,89 @@ public string SelectionNoun } } + /// Where a scope node's checkbox stands: nothing under it chosen, some of it, or all of it. + public enum SubtreeSelection { None, Some, All } + + /// + /// The roles a scope node's checkbox acts on: everything at or below it that can actually be + /// activated. A role already active, or view-only, is not something the checkbox can take, so + /// it is left out of both the count and the toggle. + /// + public IReadOnlyList SubtreeKeys(ScopeNode node) + { + ArgumentNullException.ThrowIfNull(node); + return [.. node.AllRoles.Select(r => r.Key).Where(CanSelect)]; + } + + /// + /// Whether a role's own checkbox would be enabled: activatable, and not already active, + /// scheduled or awaiting approval — there is nothing for a bulk activation to do with those. + /// A failed one can be chosen again. + /// + public bool CanSelect(RoleKey key) + { + if (!CanActivate(key)) + { + return false; + } + + return Assignment(key) is not { } assignment || assignment.Status.Kind == AssignmentStatusKind.Failed; + } + + public SubtreeSelection SubtreeState(ScopeNode node) + { + var keys = SubtreeKeys(node); + if (keys.Count == 0) + { + return SubtreeSelection.None; + } + + var chosen = keys.Count(Selection.Contains); + if (chosen == 0) + { + return SubtreeSelection.None; + } + + return chosen == keys.Count ? SubtreeSelection.All : SubtreeSelection.Some; + } + + /// + /// "Select every eligibility under this scope", and pressing it again lets them all go. A + /// partly chosen subtree fills up rather than emptying: the checkbox is offering the rest. + /// Returns how many keys the press added or removed, for the confirmation the row shows. + /// + public int ToggleSubtree(ScopeNode node) + { + var keys = SubtreeKeys(node); + if (keys.Count == 0) + { + return 0; + } + + var changed = 0; + if (SubtreeState(node) == SubtreeSelection.All) + { + changed = keys.Count(Selection.Remove); + } + else + { + foreach (var key in keys) + { + if (Selection.Add(key)) + { + changed++; + } + } + } + + if (changed > 0) + { + Touch(); + } + + return changed; + } + public void ToggleSelection(RoleKey key) { if (!CanActivate(key)) diff --git a/windows/src/Elevate.App.Model/ViewModels/AppModel.cs b/windows/src/Elevate.App.Model/ViewModels/AppModel.cs index 737b30cb..05b51984 100644 --- a/windows/src/Elevate.App.Model/ViewModels/AppModel.cs +++ b/windows/src/Elevate.App.Model/ViewModels/AppModel.cs @@ -85,6 +85,12 @@ public string SearchQuery public HashSet CollapsedIdentities { get; } = []; + /// + /// Scope nodes of the Azure pivot the user has closed, keyed by ScopeNodeKey. Scopes + /// start open, like tenants do, so nothing an account is eligible for is hidden on first sight. + /// + public HashSet CollapsedScopes { get; } = []; + public HashSet Selection { get; } = []; public string? StartupError diff --git a/windows/src/Elevate.App/Views/PanelItems.cs b/windows/src/Elevate.App/Views/PanelItems.cs index 3c95987b..9ffa57ef 100644 --- a/windows/src/Elevate.App/Views/PanelItems.cs +++ b/windows/src/Elevate.App/Views/PanelItems.cs @@ -179,6 +179,23 @@ public RoleRow(RoleKey roleKey, bool isSummary) /// Added since the last discovery: a "new" badge and a faint accent tint until the second panel open. public bool IsNew { get => _isNew; set => SetProperty(ref _isNew, value); } + private int _depth; + + /// How deep under the Azure pivot's scope tree this row sits; 0 everywhere else. + public int Depth + { + get => _depth; + set + { + if (SetProperty(ref _depth, value)) + { + OnPropertyChanged(nameof(Indent)); + } + } + } + + public Thickness Indent => PanelIndent.For(Depth); + // Derived, for x:Bind (recomputed through the notifications of the properties above). public Visibility DetailVisibility => string.IsNullOrEmpty(Detail) ? Visibility.Collapsed : Visibility.Visible; @@ -279,6 +296,7 @@ public void CopyFrom(RoleRow other) PolicyTooltip = other.PolicyTooltip; RequiresApproval = other.RequiresApproval; IsNew = other.IsNew; + Depth = other.Depth; OnPropertyChanged(string.Empty); } @@ -286,6 +304,119 @@ public void CopyFrom(RoleRow other) public void RaiseAll() => OnPropertyChanged(string.Empty); } +/// +/// How far a row of the Azure pivot's scope tree is pushed in. The panel is narrow and a +/// management group path is long, so a step is small and the tree stops indenting after a few +/// levels rather than squeezing the text off the row — the path on the header says where you are. +/// +public static class PanelIndent +{ + private const double Step = 12; + private const int MaxSteps = 3; + + public static Thickness For(int depth) => new(Math.Min(Math.Max(depth, 0), MaxSteps) * Step, 0, 0, 0); +} + +/// +/// One scope in the Azure pivot's tree: a management group, subscription, resource group or +/// resource that has roles beneath it. The chevron opens it and the checkbox, in select mode, +/// takes everything under it in one press. +/// +public sealed class ScopeRow : PanelItem +{ + public ScopeRow(TenantKey tenantKey, string scope) + { + TenantKey = tenantKey; + Scope = scope; + Key = "scope:" + tenantKey.IdentityId + ":" + tenantKey.TenantId + ":" + scope; + } + + public override string Key { get; } + + public TenantKey TenantKey { get; } + + /// The ARM path this node stands for; the tooltip shows it in full. + public string Scope { get; } + + private string _title = string.Empty; + private string? _ancestors; + private string _kindLabel = string.Empty; + private int _roleCount; + private bool _expanded = true; + private bool _selectMode; + private bool _selectEnabled; + private bool? _selected; + private int _depth; + + public string Title { get => _title; set => SetProperty(ref _title, value); } + + /// "Alpha /" — scopes folded into this one because they only passed through. + public string? Ancestors { get => _ancestors; set => SetProperty(ref _ancestors, value); } + + /// "subscription", "resource group": what kind of scope this is. + public string KindLabel { get => _kindLabel; set => SetProperty(ref _kindLabel, value); } + + /// Every eligibility at or below this node, so the count says what the checkbox would take. + public int RoleCount { get => _roleCount; set => SetProperty(ref _roleCount, value); } + + public bool Expanded { get => _expanded; set => SetProperty(ref _expanded, value); } + + public bool SelectMode { get => _selectMode; set => SetProperty(ref _selectMode, value); } + + /// False when nothing under the node can be activated — all active already, or view-only. + public bool SelectEnabled { get => _selectEnabled; set => SetProperty(ref _selectEnabled, value); } + + /// Three-state: null when only part of the subtree is chosen. + public bool? Selected { get => _selected; set => SetProperty(ref _selected, value); } + + public int Depth + { + get => _depth; + set + { + if (SetProperty(ref _depth, value)) + { + OnPropertyChanged(nameof(Indent)); + } + } + } + + // Derived, for x:Bind. + public Thickness Indent => PanelIndent.For(Depth); + + public string ChevronGlyph => Expanded ? "" : ""; + + public string ChevronLabel => Expanded ? $"Collapse {Title}" : $"Expand {Title}"; + + public Visibility AncestorsVisibility => string.IsNullOrEmpty(Ancestors) ? Visibility.Collapsed : Visibility.Visible; + + public Visibility SelectVisibility => SelectMode ? Visibility.Visible : Visibility.Collapsed; + + /// "12 roles" beside the name: what a press of the checkbox would select. + public string CountText => RoleCount == 1 ? "1 role" : string.Create(CultureInfo.InvariantCulture, $"{RoleCount} roles"); + + public string SelectLabel => RoleCount == 1 + ? $"Select the role under {Title}" + : string.Create(CultureInfo.InvariantCulture, $"Select all {RoleCount} roles under {Title}"); + + public string Tooltip => string.IsNullOrEmpty(Ancestors) ? Scope : Ancestors + " " + Title + "\n" + Scope; + + public void CopyFrom(ScopeRow other) + { + ArgumentNullException.ThrowIfNull(other); + Title = other.Title; + Ancestors = other.Ancestors; + KindLabel = other.KindLabel; + RoleCount = other.RoleCount; + Expanded = other.Expanded; + SelectMode = other.SelectMode; + SelectEnabled = other.SelectEnabled; + Selected = other.Selected; + Depth = other.Depth; + OnPropertyChanged(string.Empty); + } +} + /// One request awaiting this user's decision, in the pinned "Approvals" group. public sealed class ApprovalRow(string requestId) : PanelItem { @@ -538,10 +669,13 @@ public sealed partial class PanelItemTemplateSelector : DataTemplateSelector public DataTemplate? Approval { get; set; } + public DataTemplate? Scope { get; set; } + protected override DataTemplate? SelectTemplateCore(object item) => item switch { RoleRow => Role, ApprovalRow => Approval, + ScopeRow => Scope, _ => Note, }; @@ -798,12 +932,60 @@ private static void AddTenantRows(AppModel model, PanelGroup group, TenantContex group.Add(new NoteRow(prefix + "note", EmptyText(model, tenant)) { ConfigureTenant = manual ? tenant.Key : null }); } + if (model.PanelTab == PanelTab.Azure) + { + // The Azure pivot reads its scope strings as the hierarchy they are; the tree is + // flattened back into rows so it still lives in the panel's one list. + AddScopeTreeRows(model, group, tenant, now); + return; + } + foreach (var role in roles) { group.Add(RoleRowFor(model, role, now)); } } + /// The Azure pivot's rows: a header for each scope that branches, its roles beneath it. + private static void AddScopeTreeRows(AppModel model, PanelGroup group, TenantContext tenant, DateTimeOffset now) + { + var tree = model.AzureTree(tenant.Key); + foreach (var entry in ScopeTree.Flatten(tree, n => model.IsScopeCollapsed(tenant.Key, n))) + { + if (entry.Role is { } role) + { + var row = RoleRowFor(model, role, now); + row.Depth = entry.Depth; + group.Add(row); + continue; + } + + group.Add(ScopeRowFor(model, tenant, entry.Node!, entry.Depth)); + } + } + + private static ScopeRow ScopeRowFor(AppModel model, TenantContext tenant, ScopeNode node, int depth) + { + var selectable = model.SubtreeKeys(node); + return new ScopeRow(tenant.Key, node.Scope) + { + Title = node.Title, + Ancestors = node.Ancestors.Count == 0 ? null : string.Join(" / ", node.Ancestors) + " /", + KindLabel = ArmScope.Label(node.Kind), + RoleCount = node.RoleCount, + Depth = depth, + Expanded = !model.IsScopeCollapsed(tenant.Key, node), + SelectMode = model.SelectMode, + SelectEnabled = selectable.Count > 0, + Selected = model.SubtreeState(node) switch + { + AppModel.SubtreeSelection.All => true, + AppModel.SubtreeSelection.Some => null, + _ => false, + }, + }; + } + private static string EmptyText(AppModel model, TenantContext tenant) => (model.PanelTab, tenant.DiscoveryMode) switch { (PanelTab.Groups, _) => "No eligible groups.", @@ -1017,6 +1199,9 @@ public static void Reconcile(ObservableCollection target, IReadOnlyL case ApprovalRow approval when f is ApprovalRow freshApproval: approval.CopyFrom(freshApproval); break; + case ScopeRow scope when f is ScopeRow freshScope: + scope.CopyFrom(freshScope); + break; default: break; } diff --git a/windows/src/Elevate.App/Views/PanelView.xaml b/windows/src/Elevate.App/Views/PanelView.xaml index 6a7b6588..788ed89f 100644 --- a/windows/src/Elevate.App/Views/PanelView.xaml +++ b/windows/src/Elevate.App/Views/PanelView.xaml @@ -11,7 +11,8 @@ - + @@ -132,7 +133,42 @@ - + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/src/Elevate.App/Views/PanelView.xaml.cs b/windows/src/Elevate.App/Views/PanelView.xaml.cs index a1a9b42a..cf81d9e8 100644 --- a/windows/src/Elevate.App/Views/PanelView.xaml.cs +++ b/windows/src/Elevate.App/Views/PanelView.xaml.cs @@ -621,6 +621,55 @@ private void OnSelectClick(object sender, RoutedEventArgs e) } } + private static ScopeRow? Scope(object sender) => (sender as FrameworkElement)?.DataContext as ScopeRow; + + /// + /// The subtree checkbox: every eligibility under this scope in one press. The model finds the + /// node again from the current tree rather than the row holding one, so a refresh that arrived + /// between the draw and the click cannot select something that is no longer there. + /// + private void OnScopeSelectClick(object sender, RoutedEventArgs e) + { + if (Scope(sender) is not { } row || _model is null || Node(row) is not { } node) + { + return; + } + + _model.ToggleSubtree(node); + } + + private void OnScopeChevronClick(object sender, RoutedEventArgs e) + { + if (Scope(sender) is not { } row || _model is null || Node(row) is not { } node) + { + return; + } + + _model.ToggleScope(row.TenantKey, node); + } + + /// The tree node a scope row stands for, as the model sees it now. + private ScopeNode? Node(ScopeRow row) => + _model is null ? null : Find(_model.AzureTree(row.TenantKey), row.Scope); + + private static ScopeNode? Find(IEnumerable nodes, string scope) + { + foreach (var node in nodes) + { + if (node.Scope.Equals(scope, StringComparison.OrdinalIgnoreCase)) + { + return node; + } + + if (Find(node.Children, scope) is { } found) + { + return found; + } + } + + return null; + } + private void OnBulkActivate(object sender, RoutedEventArgs e) { if (_model is null || _model.Selection.Count == 0) diff --git a/windows/src/Elevate.Core/Support/ArmScope.cs b/windows/src/Elevate.Core/Support/ArmScope.cs new file mode 100644 index 00000000..fdf8d4b3 --- /dev/null +++ b/windows/src/Elevate.Core/Support/ArmScope.cs @@ -0,0 +1,218 @@ +using System.Globalization; + +namespace Elevate.Core.Support; + +/// What one step of an ARM scope path names. +public enum ArmScopeKind +{ + /// The tenant root, /, or a path this parser did not recognise. + Unknown, + ManagementGroup, + Subscription, + ResourceGroup, + Resource, +} + +/// +/// One step of an ARM scope path: what it names, the name itself, and the scope string that +/// reaches it. is a prefix of the scope the segment came from, so it is a +/// usable scope in its own right and can key a node of the tree. +/// +public sealed record ArmScopeSegment(ArmScopeKind Kind, string Name, string Scope); + +/// +/// Reading Azure Resource Manager scope strings as the hierarchy they already are: +/// /subscriptions/{id}/resourceGroups/{name}/providers/{ns}/{type}/{name}. The panel builds +/// its tree from this and the CLI's --under and glob --scope filters answer from it, +/// so no extra calls are needed to learn the shape. +/// +/// One thing the string cannot tell us: which management group a subscription sits under. ARM +/// writes a management group scope as a flat /providers/Microsoft.Management/managementGroups/{name} +/// and never repeats it in a subscription's scope, so management groups are roots beside the +/// subscriptions rather than above them. +/// +/// +public static class ArmScope +{ + private const string ManagementGroupPrefix = "/providers/Microsoft.Management/managementGroups/"; + + /// The steps of , outermost first; empty for a null or root scope. + public static IReadOnlyList Segments(string? scope) + { + var trimmed = (scope ?? string.Empty).Trim(); + if (trimmed.Length == 0 || trimmed == "/") + { + return []; + } + + var parts = trimmed.Split('/', StringSplitOptions.RemoveEmptyEntries); + var segments = new List(); + var path = new System.Text.StringBuilder(); + var i = 0; + + // "/providers/Microsoft.Management/managementGroups/{name}" is a whole scope, not a resource + // under something: it only ever appears on its own, so it is recognised before the loop. + if (trimmed.StartsWith(ManagementGroupPrefix, StringComparison.OrdinalIgnoreCase) && parts.Length >= 4) + { + path.Append(ManagementGroupPrefix).Append(parts[3]); + segments.Add(new ArmScopeSegment(ArmScopeKind.ManagementGroup, parts[3], path.ToString())); + i = 4; + } + + while (i < parts.Length) + { + var token = parts[i]; + if (i + 1 < parts.Length && token.Equals("subscriptions", StringComparison.OrdinalIgnoreCase)) + { + path.Append("/subscriptions/").Append(parts[i + 1]); + segments.Add(new ArmScopeSegment(ArmScopeKind.Subscription, parts[i + 1], path.ToString())); + i += 2; + } + else if (i + 1 < parts.Length && token.Equals("resourceGroups", StringComparison.OrdinalIgnoreCase)) + { + path.Append("/resourceGroups/").Append(parts[i + 1]); + segments.Add(new ArmScopeSegment(ArmScopeKind.ResourceGroup, parts[i + 1], path.ToString())); + i += 2; + } + else if (token.Equals("providers", StringComparison.OrdinalIgnoreCase) && i + 3 < parts.Length) + { + // providers/{namespace}/{type}/{name}, then (type, name) pairs for child resources. + path.Append("/providers/").Append(parts[i + 1]).Append('/').Append(parts[i + 2]).Append('/').Append(parts[i + 3]); + segments.Add(new ArmScopeSegment(ArmScopeKind.Resource, parts[i + 3], path.ToString())); + i += 4; + while (i + 1 < parts.Length) + { + path.Append('/').Append(parts[i]).Append('/').Append(parts[i + 1]); + segments.Add(new ArmScopeSegment(ArmScopeKind.Resource, parts[i + 1], path.ToString())); + i += 2; + } + } + else + { + // Something this parser does not know. Keep the rest as one step rather than + // guessing, so an unfamiliar scope still appears in the tree under a readable name. + var rest = string.Join('/', parts[i..]); + path.Append('/').Append(rest); + segments.Add(new ArmScopeSegment(ArmScopeKind.Unknown, parts[^1], path.ToString())); + break; + } + } + + return segments; + } + + /// + /// Whether is or sits below it. Compared + /// step by step, so /subscriptions/abc does not contain /subscriptions/abcdef. + /// + public static bool IsAtOrUnder(string? scope, string? ancestor) + { + var above = Segments(ancestor); + if (above.Count == 0) + { + // The root contains everything. + return true; + } + + var below = Segments(scope); + if (below.Count < above.Count) + { + return false; + } + + for (var i = 0; i < above.Count; i++) + { + if (!below[i].Scope.Equals(above[i].Scope, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + /// + /// Whether any step of is named — the plain + /// form of "under this subscription" or "under this resource group", where the user types the + /// name or id rather than the whole path. A step's own scope string is accepted too. + /// + public static bool HasSegmentNamed(string? scope, string name) + { + ArgumentNullException.ThrowIfNull(name); + var term = name.Trim().TrimEnd('/'); + if (term.Length == 0) + { + return false; + } + + return Segments(scope).Any(s => + s.Name.Equals(term, StringComparison.OrdinalIgnoreCase) + || s.Scope.Equals(term, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Whether matches the glob , where * + /// absorbs any run of characters including slashes — so /subscriptions/* reaches + /// everything in every subscription, not only the subscriptions themselves. The whole string + /// must match, and case is ignored, as everywhere else in ARM. + /// + public static bool MatchesPattern(string pattern, string? text) + { + ArgumentNullException.ThrowIfNull(pattern); + // Same glob ARM uses for action strings; ArmActions already implements it without recursion. + return ArmActions.Covers(pattern, text ?? string.Empty); + } + + /// Whether is meant as a glob rather than as plain text. + public static bool LooksLikePattern(string? term) => (term ?? string.Empty).Contains('*', StringComparison.Ordinal); + + /// + /// The display name an Azure role's Detail caption carries — "Pay-As-You-Go · subscription" + /// is written by AzureResourceProvider.Caption, and only the name part names the scope. + /// + public static string? DisplayName(string? detail) + { + var text = (detail ?? string.Empty).Trim(); + if (text.Length == 0) + { + return null; + } + + var separator = text.LastIndexOf(" · ", StringComparison.Ordinal); + var name = separator < 0 ? text : text[..separator].Trim(); + return name.Length == 0 ? null : name; + } + + /// What to call a step of the path in the panel, matching the provider's captions. + public static string Label(ArmScopeKind kind) => kind switch + { + ArmScopeKind.ManagementGroup => "management group", + ArmScopeKind.Subscription => "subscription", + ArmScopeKind.ResourceGroup => "resource group", + ArmScopeKind.Resource => "resource", + _ => "scope", + }; + + /// + /// The scope shortened for a narrow row: the last steps, with a + /// leading ellipsis for what was dropped. Truncating from the left keeps the leaf, which is + /// the part that tells two long paths apart. + /// + public static string Tail(string? scope, int steps = 2) + { + var segments = Segments(scope); + if (segments.Count == 0) + { + return "/"; + } + + var take = Math.Max(1, steps); + if (segments.Count <= take) + { + return scope?.Trim() ?? "/"; + } + + var names = segments.Skip(segments.Count - take).Select(s => s.Name); + return string.Create(CultureInfo.InvariantCulture, $"…/{string.Join('/', names)}"); + } +} diff --git a/windows/src/Elevate.Core/Support/PanelFilter.cs b/windows/src/Elevate.Core/Support/PanelFilter.cs index 43c617e4..87e28872 100644 --- a/windows/src/Elevate.Core/Support/PanelFilter.cs +++ b/windows/src/Elevate.Core/Support/PanelFilter.cs @@ -32,7 +32,10 @@ public static bool Matches(string? query, EligibleRole role, string tenantName, return true; } - string?[] fields = [role.DisplayName, role.Detail, role.ViaGroup, tenantName, upn]; + // The whole ARM path too: a role row only shows the scope's caption, but "prod" should + // find an eligibility whose subscription is named only in the path. + var path = role.Key.Scope is AzureResourceScope azure ? azure.Scope : null; + string?[] fields = [role.DisplayName, role.Detail, role.ViaGroup, tenantName, upn, path]; return fields.Any(f => Matches(query, f ?? string.Empty)); } } diff --git a/windows/src/Elevate.Core/Support/ScopeTree.cs b/windows/src/Elevate.Core/Support/ScopeTree.cs new file mode 100644 index 00000000..e904ae5f --- /dev/null +++ b/windows/src/Elevate.Core/Support/ScopeTree.cs @@ -0,0 +1,225 @@ +using Elevate.Core.Models; + +namespace Elevate.Core.Support; + +/// +/// One scope in the Azure tab's tree: the roles held directly on it, and the scopes below it that +/// hold roles of their own. Built from the eligibilities alone — see . +/// +public sealed class ScopeNode +{ + internal ScopeNode(ArmScopeKind kind, string name, string scope) + { + Kind = kind; + Name = name; + Scope = scope; + } + + public ArmScopeKind Kind { get; } + + /// The ARM name: a subscription id, a resource group name. + public string Name { get; } + + /// The whole scope string this node stands for. + public string Scope { get; } + + /// + /// The scope's friendly name, when some eligibility sits on this exact scope and the service + /// told us one. A subscription that is only an ancestor here has no caption to borrow, so it + /// shows its id. + /// + public string? DisplayName { get; internal set; } + + /// What to show: the friendly name when there is one, otherwise the ARM name. + public string Title => DisplayName ?? Name; + + /// + /// Scopes above this one that hold no role and lead nowhere else, outermost first. A + /// subscription whose only content is one resource group is not worth a row and a level of + /// indent of its own in a panel this narrow, so it is folded into the node below and named + /// here instead — the view draws it as a dimmed "Alpha /" ahead of the title. + /// + public IReadOnlyList Ancestors { get; internal set; } = []; + + /// The folded scopes and this one, as one line: "Alpha / prod". + public string Path => Ancestors.Count == 0 ? Title : string.Join(" / ", Ancestors.Append(Title)); + + /// Roles held on this exact scope. + public IReadOnlyList Roles { get; internal set; } = []; + + public IReadOnlyList Children { get; internal set; } = []; + + /// Every role at or below this node. + public int RoleCount => Roles.Count + Children.Sum(c => c.RoleCount); + + /// Every role at or below this node, in the order the tree shows them. + public IEnumerable AllRoles => Roles.Concat(Children.SelectMany(c => c.AllRoles)); +} + +/// One line of the flattened tree: a scope header, or a role beneath one. +public sealed record ScopeTreeEntry(int Depth, ScopeNode? Node, EligibleRole? Role) +{ + public bool IsScope => Node is not null && Role is null; +} + +/// +/// Turns a tenant's Azure eligibilities into the management group / subscription / resource group / +/// resource tree the panel draws, and flattens it back into rows. Kept out of the view so both the +/// row builder and the tests can reach it. +/// +public static class ScopeTree +{ + /// + /// The tree for , roots first. Roles that are not Azure resource roles + /// are ignored; the caller has already narrowed to the Azure tab. + /// + public static IReadOnlyList Build(IEnumerable roles) + { + ArgumentNullException.ThrowIfNull(roles); + + var byScope = new Dictionary(StringComparer.OrdinalIgnoreCase); + var roots = new List(); + + foreach (var role in roles) + { + if (role.Key.Scope is not AzureResourceScope azure) + { + continue; + } + + var segments = ArmScope.Segments(azure.Scope); + if (segments.Count == 0) + { + continue; + } + + Builder? parent = null; + foreach (var segment in segments) + { + if (!byScope.TryGetValue(segment.Scope, out var node)) + { + node = new Builder(segment.Kind, segment.Name, segment.Scope); + byScope[segment.Scope] = node; + if (parent is null) + { + roots.Add(node); + } + else + { + parent.Children.Add(node); + } + } + + parent = node; + } + + // The caption the service gave names this role's own scope, not its ancestors. + parent!.Roles.Add(role); + parent.DisplayName ??= ArmScope.DisplayName(role.Detail); + } + + return [.. roots.Select(Freeze).OrderBy(n => n.Kind).ThenBy(n => n.Title, StringComparer.OrdinalIgnoreCase)]; + } + + /// + /// The tree as rows, outermost first, skipping the children of a collapsed node. + /// is asked once per node; while the panel is filtering the + /// caller passes a predicate that is always false, so every match stays visible in place. + /// + /// A scope that only leads to one role and branches nowhere is left out and its role row takes + /// its place: sixty subscriptions with one eligibility each should not become a hundred and + /// twenty rows. The role row still carries the scope's caption, as it does today. + /// + /// + public static IReadOnlyList Flatten( + IEnumerable nodes, Func? isCollapsed = null) + { + ArgumentNullException.ThrowIfNull(nodes); + var rows = new List(); + Walk(nodes, 0, isCollapsed ?? (_ => false), rows); + return rows; + } + + /// Whether the tree would draw a header for at all. + public static bool IsRendered(ScopeNode node) + { + ArgumentNullException.ThrowIfNull(node); + return node.Children.Count > 0 || node.Roles.Count > 1; + } + + private static void Walk(IEnumerable nodes, int depth, Func isCollapsed, List rows) + { + foreach (var node in nodes) + { + if (!IsRendered(node)) + { + // Elided: one role, nowhere to branch. Its row stands where the header would have. + foreach (var role in node.Roles) + { + rows.Add(new ScopeTreeEntry(depth, node, role)); + } + + continue; + } + + rows.Add(new ScopeTreeEntry(depth, node, null)); + if (isCollapsed(node)) + { + continue; + } + + foreach (var role in node.Roles) + { + rows.Add(new ScopeTreeEntry(depth + 1, node, role)); + } + + Walk(node.Children, depth + 1, isCollapsed, rows); + } + } + + private sealed class Builder(ArmScopeKind kind, string name, string scope) + { + public ArmScopeKind Kind { get; } = kind; + + public string Name { get; } = name; + + public string Scope { get; } = scope; + + public string? DisplayName { get; set; } + + public List Roles { get; } = []; + + public List Children { get; } = []; + } + + private static string Title(Builder builder) => builder.DisplayName ?? builder.Name; + + private static ScopeNode Freeze(Builder builder) + { + // Fold a chain that only passes through — no roles of its own, one way down — into the + // node where the chain ends. Selecting that node still reaches everything the folded + // scopes reached, because the chain is a straight line. + var ancestors = new List(); + var deepest = builder; + while (deepest.Roles.Count == 0 && deepest.Children.Count == 1) + { + ancestors.Add(Title(deepest)); + deepest = deepest.Children[0]; + } + + var node = new ScopeNode(deepest.Kind, deepest.Name, deepest.Scope) + { + DisplayName = deepest.DisplayName, + Ancestors = ancestors, + Roles = [.. deepest.Roles.OrderBy(r => r.DisplayName, StringComparer.OrdinalIgnoreCase)], + }; + node.Children = + [ + .. deepest.Children + .Select(Freeze) + .OrderBy(c => c.Kind) + .ThenBy(c => c.Title, StringComparer.OrdinalIgnoreCase), + ]; + return node; + } +} diff --git a/windows/tests/Elevate.App.Tests/AppModelScopeTreeTests.cs b/windows/tests/Elevate.App.Tests/AppModelScopeTreeTests.cs new file mode 100644 index 00000000..27e8454a --- /dev/null +++ b/windows/tests/Elevate.App.Tests/AppModelScopeTreeTests.cs @@ -0,0 +1,200 @@ +using Elevate.App.Services; +using Elevate.App.Tests.Support; +using Elevate.App.ViewModels; +using Elevate.Core.Models; +using Elevate.Core.Storage; +using Elevate.Core.Support; +using FluentAssertions; + +namespace Elevate.App.Tests; + +/// The Azure pivot's scope tree: what it groups, what a search leaves, and what the subtree checkbox takes. +public class AppModelScopeTreeTests +{ + private const string SubA = "/subscriptions/aaaaaaaa-0000-0000-0000-000000000000"; + private const string SubB = "/subscriptions/bbbbbbbb-0000-0000-0000-000000000000"; + + private static EligibleRole Azure(string scope, string name, string? detail = null) => + new(Sample.Key(new AzureResourceScope(scope, "rd-" + name)), name, RoleSource.Discovered, RolePolicy.ManualDefault, Detail: detail); + + /// Two subscriptions: one branching into resource groups, one holding a single role. + private static async Task SeededAsync() + { + var state = new AppState { Identities = [Sample.Identity()], Tenants = [Sample.Tenant()] }; + var test = await TestModel.BootstrappedAsync(state); + test.Model.Roles[Sample.TenantKey] = + [ + Azure(SubA, "Owner", "Alpha · subscription"), + Azure(SubA + "/resourceGroups/prod-rg", "Contributor", "prod-rg · resource group"), + Azure(SubA + "/resourceGroups/test-rg", "Reader", "test-rg · resource group"), + Azure(SubB, "Contributor", "Bravo · subscription"), + Sample.Role(Sample.EntraKey, "Global Reader"), + ]; + return test; + } + + [Fact] + public async Task AzureTreeGroupsResourceGroupsUnderTheirSubscriptionAndLeavesEntraOut() + { + using var test = await SeededAsync(); + var tree = test.Model.AzureTree(Sample.TenantKey); + + tree.Select(n => n.Title).Should().Equal("Alpha", "Bravo"); + tree[0].Children.Select(n => n.Title).Should().Equal("prod-rg", "test-rg"); + tree[0].RoleCount.Should().Be(3); + tree[1].RoleCount.Should().Be(1); + } + + [Fact] + public async Task AzureTreeNarrowsWithTheSearchBox() + { + using var test = await SeededAsync(); + test.Model.SearchQuery = "prod-rg"; + + var tree = test.Model.AzureTree(Sample.TenantKey); + tree.Should().ContainSingle(); + tree[0].AllRoles.Select(r => r.DisplayName).Should().Equal("Contributor"); + } + + [Fact] + public async Task TheSearchBoxReachesTheArmPathEvenWhenNoCaptionShowsIt() + { + using var test = await SeededAsync(); + test.Model.SearchQuery = "bbbbbbbb"; + + test.Model.AzureTree(Sample.TenantKey).SelectMany(n => n.AllRoles).Should().ContainSingle(); + } + + [Fact] + public async Task ACollapsedScopeReopensWhenToggledAgain() + { + using var test = await SeededAsync(); + var model = test.Model; + var alpha = model.AzureTree(Sample.TenantKey)[0]; + + model.IsScopeCollapsed(Sample.TenantKey, alpha).Should().BeFalse("scopes start open, as tenants do"); + model.ToggleScope(Sample.TenantKey, alpha); + model.IsScopeCollapsed(Sample.TenantKey, alpha).Should().BeTrue(); + model.ToggleScope(Sample.TenantKey, alpha); + model.IsScopeCollapsed(Sample.TenantKey, alpha).Should().BeFalse(); + } + + [Fact] + public async Task AClosedScopeStillShowsItsMatchesWhileSearching() + { + // A match must never be hidden behind a node the user closed earlier. + using var test = await SeededAsync(); + var model = test.Model; + model.ToggleScope(Sample.TenantKey, model.AzureTree(Sample.TenantKey)[0]); + + model.SearchQuery = "prod"; + var tree = model.AzureTree(Sample.TenantKey); + tree.Should().NotBeEmpty(); + model.IsScopeCollapsed(Sample.TenantKey, tree[0]).Should().BeFalse(); + } + + [Fact] + public async Task ToggleSubtreeTakesEveryEligibilityUnderTheScope() + { + using var test = await SeededAsync(); + var model = test.Model; + var alpha = model.AzureTree(Sample.TenantKey)[0]; + + model.SubtreeState(alpha).Should().Be(AppModel.SubtreeSelection.None); + model.ToggleSubtree(alpha).Should().Be(3); + model.SubtreeState(alpha).Should().Be(AppModel.SubtreeSelection.All); + model.Selection.Should().HaveCount(3); + } + + [Fact] + public async Task PressingAFullyChosenSubtreeAgainLetsItGo() + { + using var test = await SeededAsync(); + var model = test.Model; + var alpha = model.AzureTree(Sample.TenantKey)[0]; + + model.ToggleSubtree(alpha); + model.ToggleSubtree(alpha).Should().Be(3); + model.Selection.Should().BeEmpty(); + } + + [Fact] + public async Task APartlyChosenSubtreeFillsUpRatherThanEmptying() + { + using var test = await SeededAsync(); + var model = test.Model; + var alpha = model.AzureTree(Sample.TenantKey)[0]; + model.ToggleSelection(alpha.Roles[0].Key); + + model.SubtreeState(alpha).Should().Be(AppModel.SubtreeSelection.Some); + model.ToggleSubtree(alpha).Should().Be(2, because: "the one already chosen is left where it is"); + model.SubtreeState(alpha).Should().Be(AppModel.SubtreeSelection.All); + } + + [Fact] + public async Task ASubtreeCheckboxOnlyTakesWhatCanActuallyBeActivated() + { + using var test = await SeededAsync(); + var model = test.Model; + var alpha = model.AzureTree(Sample.TenantKey)[0]; + model.Active[alpha.Roles[0].Key] = Sample.Assignment(alpha.Roles[0].Key); + + model.SubtreeKeys(alpha).Should().HaveCount(2); + model.ToggleSubtree(alpha).Should().Be(2); + model.SubtreeState(alpha).Should().Be(AppModel.SubtreeSelection.All); + } + + [Fact] + public async Task ASubtreeWithNothingToTakeReportsNoSelection() + { + using var test = await SeededAsync(); + var model = test.Model; + var bravo = model.AzureTree(Sample.TenantKey)[1]; + model.Active[bravo.Roles[0].Key] = Sample.Assignment(bravo.Roles[0].Key); + + model.SubtreeKeys(bravo).Should().BeEmpty(); + model.SubtreeState(bravo).Should().Be(AppModel.SubtreeSelection.None); + model.ToggleSubtree(bravo).Should().Be(0); + } + + [Fact] + public async Task TheSameScopeUnderTwoAccountsCollapsesIndependently() + { + var state = new AppState + { + Identities = [Sample.Identity("id-1"), Sample.Identity("id-2")], + Tenants = [Sample.Tenant(identityId: "id-1"), Sample.Tenant(identityId: "id-2", tenantId: "tenant-2")], + }; + using var test = await TestModel.BootstrappedAsync(state); + var model = test.Model; + var first = new TenantKey("id-1", Sample.TenantId); + var second = new TenantKey("id-2", "tenant-2"); + model.Roles[first] = + [ + new EligibleRole(Sample.Key(new AzureResourceScope(SubA, "a"), "id-1"), "Owner", RoleSource.Discovered, RolePolicy.ManualDefault), + new EligibleRole(Sample.Key(new AzureResourceScope(SubA + "/resourceGroups/rg", "b"), "id-1"), "Reader", RoleSource.Discovered, RolePolicy.ManualDefault), + ]; + model.Roles[second] = + [ + new EligibleRole(Sample.Key(new AzureResourceScope(SubA, "a"), "id-2", "tenant-2"), "Owner", RoleSource.Discovered, RolePolicy.ManualDefault), + new EligibleRole(Sample.Key(new AzureResourceScope(SubA + "/resourceGroups/rg", "b"), "id-2", "tenant-2"), "Reader", RoleSource.Discovered, RolePolicy.ManualDefault), + ]; + + model.ToggleScope(first, model.AzureTree(first)[0]); + model.IsScopeCollapsed(first, model.AzureTree(first)[0]).Should().BeTrue(); + model.IsScopeCollapsed(second, model.AzureTree(second)[0]).Should().BeFalse(); + } + + [Fact] + public async Task TheFlattenedRowsSkipWhatAClosedScopeHides() + { + using var test = await SeededAsync(); + var model = test.Model; + var tree = model.AzureTree(Sample.TenantKey); + + ScopeTree.Flatten(tree, n => model.IsScopeCollapsed(Sample.TenantKey, n)).Should().HaveCount(5); + model.ToggleScope(Sample.TenantKey, tree[0]); + ScopeTree.Flatten(tree, n => model.IsScopeCollapsed(Sample.TenantKey, n)) + .Should().HaveCount(2, because: "Alpha's header stays, its three rows go, and Bravo's single role row remains"); + } +} diff --git a/windows/tests/Elevate.Core.Tests/ArmScopeTests.cs b/windows/tests/Elevate.Core.Tests/ArmScopeTests.cs new file mode 100644 index 00000000..282d6df9 --- /dev/null +++ b/windows/tests/Elevate.Core.Tests/ArmScopeTests.cs @@ -0,0 +1,142 @@ +using Elevate.Core.Support; +using FluentAssertions; + +namespace Elevate.Core.Tests; + +public class ArmScopeTests +{ + private const string Mg = "/providers/Microsoft.Management/managementGroups/platform"; + private const string Sub = "/subscriptions/11111111-1111-1111-1111-111111111111"; + private const string Rg = Sub + "/resourceGroups/prod-rg"; + private const string Vm = Rg + "/providers/Microsoft.Compute/virtualMachines/web01"; + + [Fact] + public void Segments_ReadsSubscriptionResourceGroupAndResource() + { + var segments = ArmScope.Segments(Vm); + segments.Select(s => s.Kind).Should().Equal( + ArmScopeKind.Subscription, ArmScopeKind.ResourceGroup, ArmScopeKind.Resource); + segments.Select(s => s.Name).Should().Equal("11111111-1111-1111-1111-111111111111", "prod-rg", "web01"); + segments.Select(s => s.Scope).Should().Equal(Sub, Rg, Vm); + } + + [Fact] + public void Segments_ReadsAManagementGroupAsOneStep() + { + var segments = ArmScope.Segments(Mg); + segments.Should().ContainSingle(); + segments[0].Should().Be(new ArmScopeSegment(ArmScopeKind.ManagementGroup, "platform", Mg)); + } + + [Fact] + public void Segments_ReadsAResourceDirectlyUnderASubscription() + { + var scope = Sub + "/providers/Microsoft.Network/virtualNetworks/hub"; + ArmScope.Segments(scope).Select(s => s.Kind).Should().Equal(ArmScopeKind.Subscription, ArmScopeKind.Resource); + } + + [Fact] + public void Segments_ReadsChildResourcesAsStepsOfTheirOwn() + { + var scope = Vm + "/extensions/monitor"; + var segments = ArmScope.Segments(scope); + segments.Select(s => s.Name).Should().Equal("11111111-1111-1111-1111-111111111111", "prod-rg", "web01", "monitor"); + segments[^1].Scope.Should().Be(scope); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("/")] + public void Segments_IsEmptyForTheRoot(string? scope) => ArmScope.Segments(scope).Should().BeEmpty(); + + [Fact] + public void Segments_KeepsAnUnfamiliarPathAsOneStepRatherThanDroppingIt() + { + var segments = ArmScope.Segments("/tenants/contoso/widgets/one"); + segments.Should().ContainSingle(); + segments[0].Kind.Should().Be(ArmScopeKind.Unknown); + } + + [Fact] + public void Segments_IgnoresCaseInThePathKeywords() + { + var segments = ArmScope.Segments("/SUBSCRIPTIONS/abc/RESOURCEGROUPS/rg"); + segments.Select(s => s.Kind).Should().Equal(ArmScopeKind.Subscription, ArmScopeKind.ResourceGroup); + } + + [Theory] + [InlineData(Vm, Sub, true)] + [InlineData(Vm, Rg, true)] + [InlineData(Vm, Vm, true)] + [InlineData(Rg, Vm, false)] + [InlineData(Sub, Rg, false)] + [InlineData(Mg, Sub, false)] + [InlineData(Vm, "/", true)] + public void IsAtOrUnder_ComparesStepByStep(string scope, string ancestor, bool expected) => + ArmScope.IsAtOrUnder(scope, ancestor).Should().Be(expected); + + [Fact] + public void IsAtOrUnder_DoesNotTreatANamePrefixAsAnAncestor() + { + // "/subscriptions/abc" must not swallow "/subscriptions/abcdef". + ArmScope.IsAtOrUnder("/subscriptions/abcdef", "/subscriptions/abc").Should().BeFalse(); + } + + [Fact] + public void IsAtOrUnder_IgnoresCase() => + ArmScope.IsAtOrUnder(Vm, Rg.ToUpperInvariant()).Should().BeTrue(); + + [Theory] + [InlineData(Vm, "prod-rg", true)] + [InlineData(Vm, "PROD-RG", true)] + [InlineData(Vm, "web01", true)] + [InlineData(Vm, "prod", false)] + [InlineData(Vm, Rg, true)] + [InlineData(Vm, Rg + "/", true)] + [InlineData(Mg, "platform", true)] + [InlineData(Vm, "", false)] + public void HasSegmentNamed_MatchesAStepsNameOrItsWholeScope(string scope, string name, bool expected) => + ArmScope.HasSegmentNamed(scope, name).Should().Be(expected); + + [Theory] + [InlineData("/subscriptions/*", Sub, true)] + [InlineData("/subscriptions/*", Rg, true)] + [InlineData("/subscriptions/*", Vm, true)] + [InlineData("/subscriptions/*", Mg, false)] + [InlineData("*/resourceGroups/prod-*", Vm, true)] + [InlineData("*/resourceGroups/prod-*", Sub, false)] + [InlineData("*managementGroups*", Mg, true)] + public void MatchesPattern_LetsStarCrossSlashes(string pattern, string scope, bool expected) => + ArmScope.MatchesPattern(pattern, scope).Should().Be(expected); + + [Fact] + public void MatchesPattern_MatchesTheWholeStringSoAPrefixAloneIsNotEnough() => + ArmScope.MatchesPattern("/subscriptions", Sub).Should().BeFalse(); + + [Theory] + [InlineData("prod*", true)] + [InlineData("*", true)] + [InlineData("prod", false)] + [InlineData(null, false)] + public void LooksLikePattern_IsAboutTheStar(string? term, bool expected) => + ArmScope.LooksLikePattern(term).Should().Be(expected); + + [Theory] + [InlineData("Pay-As-You-Go · subscription", "Pay-As-You-Go")] + [InlineData("prod-rg · resource group", "prod-rg")] + [InlineData("just a name", "just a name")] + [InlineData("", null)] + [InlineData(null, null)] + public void DisplayName_DropsTheKindTheCaptionAppends(string? detail, string? expected) => + ArmScope.DisplayName(detail).Should().Be(expected); + + [Fact] + public void Tail_KeepsTheLeafAndDropsWhatIsAbove() + { + ArmScope.Tail(Vm).Should().Be("…/prod-rg/web01"); + ArmScope.Tail(Vm, 1).Should().Be("…/web01"); + ArmScope.Tail(Sub).Should().Be(Sub); + ArmScope.Tail("/").Should().Be("/"); + } +} diff --git a/windows/tests/Elevate.Core.Tests/ScopeTreeTests.cs b/windows/tests/Elevate.Core.Tests/ScopeTreeTests.cs new file mode 100644 index 00000000..aa06792d --- /dev/null +++ b/windows/tests/Elevate.Core.Tests/ScopeTreeTests.cs @@ -0,0 +1,202 @@ +using Elevate.Core.Models; +using Elevate.Core.Support; +using FluentAssertions; + +namespace Elevate.Core.Tests; + +public class ScopeTreeTests +{ + private const string SubA = "/subscriptions/aaaaaaaa-0000-0000-0000-000000000000"; + private const string SubB = "/subscriptions/bbbbbbbb-0000-0000-0000-000000000000"; + private const string Mg = "/providers/Microsoft.Management/managementGroups/platform"; + + private static EligibleRole Role(string name, string scope, string? detail = null) => + new(new RoleKey("i", "t", new AzureResourceScope(scope, "rd-" + name)), + name, RoleSource.Discovered, RolePolicy.ManualDefault, Detail: detail); + + [Fact] + public void Build_NestsResourceGroupsUnderTheirSubscription() + { + var tree = ScopeTree.Build( + [ + Role("Contributor", SubA + "/resourceGroups/prod", "prod · resource group"), + Role("Reader", SubA + "/resourceGroups/test", "test · resource group"), + Role("Owner", SubA, "Platform · subscription"), + ]); + + tree.Should().ContainSingle(); + var sub = tree[0]; + sub.Kind.Should().Be(ArmScopeKind.Subscription); + sub.Title.Should().Be("Platform", because: "the role held on the subscription itself carries its caption"); + sub.Roles.Select(r => r.DisplayName).Should().Equal("Owner"); + sub.Children.Select(c => c.Title).Should().Equal("prod", "test"); + sub.RoleCount.Should().Be(3); + } + + [Fact] + public void Build_ShowsTheArmNameWhenNoRoleSitsOnTheScopeItself() + { + var tree = ScopeTree.Build( + [ + Role("Contributor", SubA + "/resourceGroups/prod", "prod · resource group"), + Role("Reader", SubA + "/resourceGroups/test", "test · resource group"), + ]); + + // Nothing is eligible on the subscription, so the service never named it. + tree[0].DisplayName.Should().BeNull(); + tree[0].Title.Should().Be("aaaaaaaa-0000-0000-0000-000000000000"); + } + + [Fact] + public void Build_PutsManagementGroupsBesideSubscriptionsRatherThanAboveThem() + { + // ARM never repeats the management group in a subscription's scope, so the string cannot + // tell us the subscription sits under it. + var tree = ScopeTree.Build([Role("Reader", Mg, "Platform · management group"), Role("Owner", SubA, "A · subscription")]); + tree.Select(n => n.Kind).Should().Equal(ArmScopeKind.ManagementGroup, ArmScopeKind.Subscription); + } + + [Fact] + public void Build_IgnoresRolesThatAreNotAzureResourceRoles() + { + var entra = new EligibleRole( + new RoleKey("i", "t", new EntraDirectoryScope("rd", "/")), "Global Reader", RoleSource.Discovered, RolePolicy.ManualDefault); + ScopeTree.Build([entra, Role("Owner", SubA)]).Should().ContainSingle(); + } + + [Fact] + public void Build_SortsRootsByKindThenTitle() + { + var tree = ScopeTree.Build( + [ + Role("Owner", SubB, "Zulu · subscription"), + Role("Owner", SubA, "Alpha · subscription"), + Role("Reader", Mg, "Platform · management group"), + ]); + tree.Select(n => n.Title).Should().Equal("Platform", "Alpha", "Zulu"); + } + + [Fact] + public void Flatten_ElidesAScopeThatLeadsToOneRoleAndBranchesNowhere() + { + // The motivating case: many subscriptions, one eligibility each. A header per subscription + // would double the rows and show nothing the role row does not already say. + var tree = ScopeTree.Build([Role("Contributor", SubA, "Alpha · subscription"), Role("Contributor", SubB, "Zulu · subscription")]); + var rows = ScopeTree.Flatten(tree); + + rows.Should().HaveCount(2); + rows.Should().OnlyContain(r => !r.IsScope); + rows.Should().OnlyContain(r => r.Depth == 0); + } + + [Fact] + public void Flatten_DrawsAHeaderAsSoonAsAScopeHasSomethingToBranchInto() + { + var tree = ScopeTree.Build( + [ + Role("Owner", SubA, "Alpha · subscription"), + Role("Contributor", SubA + "/resourceGroups/prod", "prod · resource group"), + ]); + var rows = ScopeTree.Flatten(tree); + + rows.Select(r => (r.Depth, r.IsScope, r.Role?.DisplayName)).Should().Equal( + (0, true, null), + (1, false, "Owner"), + (1, false, "Contributor")); + } + + [Fact] + public void Flatten_DrawsAHeaderWhenOneScopeHoldsSeveralRoles() + { + var tree = ScopeTree.Build([Role("Owner", SubA, "Alpha · subscription"), Role("Reader", SubA, "Alpha · subscription")]); + var rows = ScopeTree.Flatten(tree); + rows[0].IsScope.Should().BeTrue(); + rows.Skip(1).Select(r => r.Role!.DisplayName).Should().Equal("Owner", "Reader"); + } + + [Fact] + public void Flatten_StopsAtACollapsedNode() + { + var tree = ScopeTree.Build( + [ + Role("Owner", SubA, "Alpha · subscription"), + Role("Contributor", SubA + "/resourceGroups/prod", "prod · resource group"), + ]); + var rows = ScopeTree.Flatten(tree, n => n.Scope == SubA); + + rows.Should().ContainSingle(); + rows[0].IsScope.Should().BeTrue(); + } + + [Fact] + public void Flatten_NestsAResourceUnderItsResourceGroup() + { + var rg = SubA + "/resourceGroups/prod"; + var tree = ScopeTree.Build( + [ + Role("Owner", rg, "prod · resource group"), + Role("Reader", rg + "/providers/Microsoft.Compute/virtualMachines/web01", "web01 · virtualmachines"), + Role("Reader", rg + "/providers/Microsoft.Compute/virtualMachines/web02", "web02 · virtualmachines"), + ]); + var rows = ScopeTree.Flatten(tree); + + // The subscription is folded away — it holds no role of its own and leads to a single child. + rows.Select(r => (r.Depth, r.IsScope)).Should().Equal( + (0, true), (1, false), (1, false), (1, false)); + rows[0].Node!.Kind.Should().Be(ArmScopeKind.ResourceGroup); + } + + [Fact] + public void Build_FoldsAPassThroughScopeIntoTheNodeBelowIt() + { + var rg = SubA + "/resourceGroups/prod"; + var tree = ScopeTree.Build( + [ + Role("Owner", rg, "prod · resource group"), + Role("Reader", rg, "prod · resource group"), + ]); + + tree.Should().ContainSingle(); + tree[0].Kind.Should().Be(ArmScopeKind.ResourceGroup); + tree[0].Ancestors.Should().Equal("aaaaaaaa-0000-0000-0000-000000000000"); + tree[0].Path.Should().Be("aaaaaaaa-0000-0000-0000-000000000000 / prod"); + tree[0].Scope.Should().Be(rg, because: "selecting the folded node must still reach the same subtree"); + } + + [Fact] + public void Build_DoesNotFoldAScopeThatHoldsARoleOfItsOwn() + { + var tree = ScopeTree.Build( + [ + Role("Owner", SubA, "Alpha · subscription"), + Role("Reader", SubA + "/resourceGroups/prod", "prod · resource group"), + ]); + tree[0].Kind.Should().Be(ArmScopeKind.Subscription); + tree[0].Ancestors.Should().BeEmpty(); + } + + [Fact] + public void Build_DoesNotFoldAScopeThatBranches() + { + var tree = ScopeTree.Build( + [ + Role("Owner", SubA + "/resourceGroups/prod", "prod · resource group"), + Role("Reader", SubA + "/resourceGroups/test", "test · resource group"), + ]); + tree[0].Kind.Should().Be(ArmScopeKind.Subscription); + tree[0].Children.Should().HaveCount(2); + } + + [Fact] + public void AllRoles_ReachesEverythingBelowTheNode() + { + var tree = ScopeTree.Build( + [ + Role("Owner", SubA, "Alpha · subscription"), + Role("Contributor", SubA + "/resourceGroups/prod", "prod · resource group"), + Role("Reader", SubA + "/resourceGroups/prod/providers/Microsoft.Compute/virtualMachines/web01", "web01 · vm"), + ]); + tree[0].AllRoles.Select(r => r.DisplayName).Should().BeEquivalentTo("Owner", "Contributor", "Reader"); + tree[0].RoleCount.Should().Be(3); + } +}