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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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: the panel's search box reaches an Azure role's whole scope path, not just the caption the
row shows, so typing part of a subscription id or a resource group that is only named in the path
now finds it. The rest of the Azure tab's scope tree follows.
([#194](https://github.com/FrodeHus/elevate/issues/194))
- 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
165 changes: 165 additions & 0 deletions macos/Sources/ElevateCore/Support/ArmScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import Foundation

/// What one step of an ARM scope path names.
public enum ArmScopeKind: Int, Hashable, Sendable, CaseIterable, Comparable {
/// The tenant root, `/`, or a path this parser did not recognise.
case unknown
case managementGroup
case subscription
case resourceGroup
case resource

public static func < (lhs: ArmScopeKind, rhs: ArmScopeKind) -> Bool { lhs.rawValue < rhs.rawValue }
}

/// One step of an ARM scope path: what it names, the name itself, and the scope string that
/// reaches it. `scope` 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 struct ArmScopeSegment: Hashable, Sendable {
public let kind: ArmScopeKind
public let name: String
public let scope: String

public init(kind: ArmScopeKind, name: String, scope: String) {
self.kind = kind
self.name = name
self.scope = 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, 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 enum ArmScope {
private static let managementGroupPrefix = "/providers/Microsoft.Management/managementGroups/"

/// The steps of `scope`, outermost first; empty for a nil or root scope.
public static func segments(_ scope: String?) -> [ArmScopeSegment] {
let trimmed = (scope ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != "/" else { return [] }

let parts = trimmed.split(separator: "/").map(String.init)
var segments: [ArmScopeSegment] = []
var path = ""
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.lowercased().hasPrefix(managementGroupPrefix.lowercased()), parts.count >= 4 {
path = managementGroupPrefix + parts[3]
segments.append(ArmScopeSegment(kind: .managementGroup, name: parts[3], scope: path))
i = 4
}

while i < parts.count {
let token = parts[i].lowercased()
if i + 1 < parts.count, token == "subscriptions" {
path += "/subscriptions/" + parts[i + 1]
segments.append(ArmScopeSegment(kind: .subscription, name: parts[i + 1], scope: path))
i += 2
} else if i + 1 < parts.count, token == "resourcegroups" {
path += "/resourceGroups/" + parts[i + 1]
segments.append(ArmScopeSegment(kind: .resourceGroup, name: parts[i + 1], scope: path))
i += 2
} else if token == "providers", i + 3 < parts.count {
// providers/{namespace}/{type}/{name}, then (type, name) pairs for child resources.
path += "/providers/" + parts[i + 1] + "/" + parts[i + 2] + "/" + parts[i + 3]
segments.append(ArmScopeSegment(kind: .resource, name: parts[i + 3], scope: path))
i += 4
while i + 1 < parts.count {
path += "/" + parts[i] + "/" + parts[i + 1]
segments.append(ArmScopeSegment(kind: .resource, name: parts[i + 1], scope: path))
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.
path += "/" + parts[i...].joined(separator: "/")
segments.append(ArmScopeSegment(kind: .unknown, name: parts[parts.count - 1], scope: path))
break
}
}

return segments
}

/// Whether `scope` is `ancestor` or sits below it. Compared step by step, so
/// `/subscriptions/abc` does not contain `/subscriptions/abcdef`.
public static func isAtOrUnder(_ scope: String?, ancestor: String?) -> Bool {
let above = segments(ancestor)
// The root contains everything.
guard !above.isEmpty else { return true }

let below = segments(scope)
guard below.count >= above.count else { return false }
return zip(below, above).allSatisfy { $0.scope.lowercased() == $1.scope.lowercased() }
}

/// Whether any step of `scope` is named `name` — the plain form of "under this subscription" or
/// "under this resource group", where the user names it rather than giving the whole path. A
/// step's own scope string is accepted too.
public static func hasSegmentNamed(_ scope: String?, name: String) -> Bool {
var term = name.trimmingCharacters(in: .whitespacesAndNewlines)
while term.hasSuffix("/") { term.removeLast() }
guard !term.isEmpty else { return false }

return segments(scope).contains {
$0.name.lowercased() == term.lowercased() || $0.scope.lowercased() == term.lowercased()
}
}

/// Whether `text` matches the glob `pattern`, 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 func matches(pattern: String, text: String?) -> Bool {
// Same glob ARM uses for action strings; ArmActions already implements it without recursion.
ArmActions.covers(pattern: pattern, action: text ?? "")
}

/// Whether `term` is meant as a glob rather than as plain text.
public static func looksLikePattern(_ term: String?) -> Bool { (term ?? "").contains("*") }

/// 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 func displayName(detail: String?) -> String? {
let text = (detail ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }

let name = text.range(of: " · ", options: .backwards).map {
String(text[text.startIndex..<$0.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
} ?? text
return name.isEmpty ? nil : name
}

/// What to call a step of the path in the panel, matching the provider's captions.
public static func label(_ kind: ArmScopeKind) -> String {
switch kind {
case .managementGroup: "management group"
case .subscription: "subscription"
case .resourceGroup: "resource group"
case .resource: "resource"
case .unknown: "scope"
}
}

/// The scope shortened for a narrow row: the last `steps` 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 func tail(_ scope: String?, steps: Int = 2) -> String {
let segments = segments(scope)
guard !segments.isEmpty else { return "/" }

let take = max(1, steps)
guard segments.count > take else {
return scope?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "/"
}

return "…/" + segments.suffix(take).map(\.name).joined(separator: "/")
}
}
6 changes: 5 additions & 1 deletion macos/Sources/ElevateCore/Support/PanelFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ public enum PanelFilter {

public static func matches(query: String, role: EligibleRole, tenantName: String, upn: String) -> Bool {
guard isActive(query) else { return true }
let 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.
let path: String
if case .azureResource(let scope, _) = role.key.scope { path = scope } else { path = "" }
let fields = [role.displayName, role.detail ?? "", role.viaGroup ?? "", tenantName, upn, path]
return fields.contains { matches(query: query, text: $0) }
}
}
179 changes: 179 additions & 0 deletions macos/Sources/ElevateCore/Support/ScopeTree.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import Foundation

/// 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 `ArmScope`.
public final class ScopeNode: Identifiable, @unchecked Sendable {
public let kind: ArmScopeKind
/// The ARM name: a subscription id, a resource group name.
public let name: String
/// The whole scope string this node stands for.
public let scope: String

/// 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 internal(set) var displayName: String?

/// 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 internal(set) var ancestors: [String] = []

/// Roles held on this exact scope.
public internal(set) var roles: [EligibleRole] = []

public internal(set) var children: [ScopeNode] = []

init(kind: ArmScopeKind, name: String, scope: String) {
self.kind = kind
self.name = name
self.scope = scope
}

public var id: String { scope }

/// What to show: the friendly name when there is one, otherwise the ARM name.
public var title: String { displayName ?? name }

/// The folded scopes and this one, as one line: "Alpha / prod".
public var path: String { ancestors.isEmpty ? title : (ancestors + [title]).joined(separator: " / ") }

/// Every role at or below this node.
public var roleCount: Int { roles.count + children.reduce(0) { $0 + $1.roleCount } }

/// Every role at or below this node, in the order the tree shows them.
public var allRoles: [EligibleRole] { roles + children.flatMap(\.allRoles) }
}

/// One line of the flattened tree: a scope header, or a role beneath one.
public struct ScopeTreeEntry: Identifiable, Sendable {
public let depth: Int
public let node: ScopeNode
public let role: EligibleRole?

public var isScope: Bool { role == nil }

/// Stable across redraws: a role appears once, under the node that owns it.
public var id: String { role.map { "role:\($0.key)" } ?? "scope:\(node.scope)" }

init(depth: Int, node: ScopeNode, role: EligibleRole?) {
self.depth = depth
self.node = node
self.role = role
}
}

/// 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
/// panel and the tests can reach it.
public enum ScopeTree {
/// The tree for `roles`, roots first. Roles that are not Azure resource roles are ignored; the
/// caller has already narrowed to the Azure tab.
public static func build(_ roles: [EligibleRole]) -> [ScopeNode] {
var byScope: [String: Builder] = [:]
var roots: [Builder] = []

for role in roles {
guard case .azureResource(let scope, _) = role.key.scope else { continue }
let segments = ArmScope.segments(scope)
guard !segments.isEmpty else { continue }

var parent: Builder?
for segment in segments {
let lookup = segment.scope.lowercased()
let node: Builder
if let existing = byScope[lookup] {
node = existing
} else {
node = Builder(kind: segment.kind, name: segment.name, scope: segment.scope)
byScope[lookup] = node
if let parent { parent.children.append(node) } else { roots.append(node) }
}
parent = node
}

// The caption the service gave names this role's own scope, not its ancestors.
parent?.roles.append(role)
if parent?.displayName == nil { parent?.displayName = ArmScope.displayName(detail: role.detail) }
}

return sorted(roots.map(freeze))
}

/// The tree as rows, outermost first, skipping the children of a collapsed node. `isCollapsed`
/// 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 func flatten(_ nodes: [ScopeNode], isCollapsed: (ScopeNode) -> Bool = { _ in false }) -> [ScopeTreeEntry] {
var rows: [ScopeTreeEntry] = []
walk(nodes, depth: 0, isCollapsed: isCollapsed, into: &rows)
return rows
}

/// Whether the tree would draw a header for `node` at all.
public static func isRendered(_ node: ScopeNode) -> Bool {
!node.children.isEmpty || node.roles.count > 1
}

private static func walk(_ nodes: [ScopeNode], depth: Int, isCollapsed: (ScopeNode) -> Bool, into rows: inout [ScopeTreeEntry]) {
for node in nodes {
guard isRendered(node) else {
// Elided: one role, nowhere to branch. Its row stands where the header would have.
for role in node.roles { rows.append(ScopeTreeEntry(depth: depth, node: node, role: role)) }
continue
}

rows.append(ScopeTreeEntry(depth: depth, node: node, role: nil))
guard !isCollapsed(node) else { continue }

for role in node.roles { rows.append(ScopeTreeEntry(depth: depth + 1, node: node, role: role)) }
walk(node.children, depth: depth + 1, isCollapsed: isCollapsed, into: &rows)
}
}

private final class Builder {
let kind: ArmScopeKind
let name: String
let scope: String
var displayName: String?
var roles: [EligibleRole] = []
var children: [Builder] = []

init(kind: ArmScopeKind, name: String, scope: String) {
self.kind = kind
self.name = name
self.scope = scope
}

var title: String { displayName ?? name }
}

private static func sorted(_ nodes: [ScopeNode]) -> [ScopeNode] {
nodes.sorted {
$0.kind == $1.kind ? $0.title.lowercased() < $1.title.lowercased() : $0.kind < $1.kind
}
}

private static func freeze(_ builder: Builder) -> ScopeNode {
// 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: [String] = []
var deepest = builder
while deepest.roles.isEmpty, deepest.children.count == 1 {
ancestors.append(deepest.title)
deepest = deepest.children[0]
}

let node = ScopeNode(kind: deepest.kind, name: deepest.name, scope: deepest.scope)
node.displayName = deepest.displayName
node.ancestors = ancestors
node.roles = deepest.roles.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
node.children = sorted(deepest.children.map(freeze))
return node
}
}
Loading
Loading