diff --git a/README.md b/README.md index c79092cd..128d5c34 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ txc workspace explain - [Collaboration](#collaboration) **Detailed guides:** -[Data Plane](docs/data-plane.md) · [Schema Management](docs/schema-management.md) · [Changeset Staging](docs/changeset-staging.md) · [Architecture](docs/architecture.md) · [Profiles & Auth](docs/profiles-and-authentication.md) · [Output Contract](docs/output-contract.md) +[Data Plane](docs/data-plane.md) · [Schema Management](docs/schema-management.md) · [Changeset Staging](docs/changeset-staging.md) · [Architecture](docs/architecture.md) · [Profiles & Auth](docs/profiles-and-authentication.md) · [Output Contract](docs/output-contract.md) · [Command Taxonomy](docs/command-taxonomy.md) --- diff --git a/TALXIS.CLI.sln b/TALXIS.CLI.sln index d1e6addf..7374ddde 100644 --- a/TALXIS.CLI.sln +++ b/TALXIS.CLI.sln @@ -45,7 +45,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Analyzers", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Abstractions", "src\TALXIS.CLI.Abstractions\TALXIS.CLI.Abstractions.csproj", "{C15B8E89-AD5F-4F61-AD7B-2DAFD43B10DD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Tenant", "src\TALXIS.CLI.Features.Tenant\TALXIS.CLI.Features.Tenant.csproj", "{A2B6722A-967F-4A84-89A9-E5EE8CF3198C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Security", "src\TALXIS.CLI.Features.Security\TALXIS.CLI.Features.Security.csproj", "{A2B6722A-967F-4A84-89A9-E5EE8CF3198C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Governance", "src\TALXIS.CLI.Features.Governance\TALXIS.CLI.Features.Governance.csproj", "{72D75231-6886-4517-B199-31FBC368F5ED}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -297,6 +299,18 @@ Global {A2B6722A-967F-4A84-89A9-E5EE8CF3198C}.Release|x64.Build.0 = Release|Any CPU {A2B6722A-967F-4A84-89A9-E5EE8CF3198C}.Release|x86.ActiveCfg = Release|Any CPU {A2B6722A-967F-4A84-89A9-E5EE8CF3198C}.Release|x86.Build.0 = Release|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Debug|x64.Build.0 = Debug|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Debug|x86.Build.0 = Debug|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Release|Any CPU.Build.0 = Release|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Release|x64.ActiveCfg = Release|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Release|x64.Build.0 = Release|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Release|x86.ActiveCfg = Release|Any CPU + {72D75231-6886-4517-B199-31FBC368F5ED}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -322,6 +336,7 @@ Global {7DFD9789-0399-4C36-BFC9-091CDA8DE4E2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C15B8E89-AD5F-4F61-AD7B-2DAFD43B10DD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {A2B6722A-967F-4A84-89A9-E5EE8CF3198C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {72D75231-6886-4517-B199-31FBC368F5ED} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {53733BD6-A32A-41B7-9472-E377AF68151F} diff --git a/docs/architecture.md b/docs/architecture.md index c9156394..b871fbef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,7 @@ src/ TALXIS.CLI.Features.Config # txc config: profiles, auth, connections, settings TALXIS.CLI.Features.Environment # txc environment: env list/create, solution/package/deployment, # user/app/team/role (Dataverse security principals) - TALXIS.CLI.Features.Tenant # txc tenant: tenant-wide role catalog and role assignment for + TALXIS.CLI.Features.Security # txc security: tenant-wide role catalog and role assignment for # Entra applications/users/groups (no Entra ID mutation) TALXIS.CLI.Features.Data # txc data: model conversion, data packages, transforms TALXIS.CLI.Features.Docs # txc docs (placeholder) diff --git a/docs/command-taxonomy.md b/docs/command-taxonomy.md new file mode 100644 index 00000000..e7464f42 --- /dev/null +++ b/docs/command-taxonomy.md @@ -0,0 +1,131 @@ +# Command Taxonomy: `security` vs `governance` vs `environment` + +This document explains where identity/access and tenant-governance commands +live in `txc`, and why — so the split is discoverable without trial and +error. + +## The three top-level groups + +``` +txc environment — one connected org: ALM, schema, data, solution, component, entity. No RBAC. +txc security — all RBAC (identity/access), tenant + Dataverse, unified via a --environment scope flag. +txc governance — tenant-wide governance configuration with no single-environment connection: environment groups, rule-based policies. +``` + +### `txc environment` — connected-org data & lifecycle only + +`environment` commands all require "connected to one specific org" as +their organizing reason: ALM (`solution`), schema (`entity`, `component`), +and data (`data`). It has **no RBAC commands** — identity/access questions +("who can do what") are a different kind of question from "what does this +org's schema/data/solution look like," so they live in `security` instead, +even for a connected Dataverse environment. + +### `txc security` — every RBAC question, one flag + +`security` answers "who has access, and to what" — for the tenant +(Entra/Power-Platform-admin identities and roles) and for a specific +Dataverse environment (systemusers, teams, security roles), through one +uniform scope flag: + +``` +txc security user list/get/create/update/delete/role [--environment ] +txc security service-principal list/get/create/update/delete/role [--environment ] +txc security role list/get [--environment ] +txc security team list/get/create/update/delete/member/role --environment (required) +txc security group list/get/role (Entra security groups; tenant-only) +``` + +**`--environment ` means exactly one thing everywhere it appears**: +"scope this RBAC operation to this Dataverse environment instead of the +tenant-wide directory." Rules: + +- It **defaults to the active profile's environment connection** when one + is set, so a connected user doesn't need to repeat an id they've already + supplied. +- It **can be passed explicitly** to target any environment's RBAC without + switching the active connection. +- `security team` has **no tenant-wide equivalent** (Dataverse teams have + no Entra analog), so `--environment` is **required** there, not optional + — never a silently-changes-behavior toggle. +- `security group` (Entra security groups) has **no Dataverse equivalent**, + so it takes no flag at all — nothing to disambiguate. + +#### Why a flag, when a flag-based design was rejected for `pac` + +`pac admin`'s flag usage is inconsistent per-verb (`list-groups`, +`add-group`, `assign-user`, `create-service-principal`, each with its own +one-off flag conventions), so a flag never reliably means the same thing +twice. `txc security`'s flag is **structural, not verb-by-verb bolted on**: +every RBAC resource that has both a tenant and a Dataverse form exposes the +*same* `--environment` flag, with the *same* fallback/override behavior, +documented once here instead of once per command. + +#### Catalog listing switches scope; assignment listing combines scope + +- `security role list [--environment ]` lists a **role catalog** — a + definition set. Tenant admin roles and Dataverse security roles are + non-overlapping catalogs, so `--environment` **switches** which catalog + you see; there is no meaningful way to merge two different catalogs. +- `security user role list` / `security service-principal role list` + `[--environment ]` list a principal's **actual assigned roles** — + their real, effective access. A principal's tenant admin role (if any) + applies everywhere; their Dataverse security roles apply only within + environment(s) they're a member of. So when `--environment ` is + supplied, this command **combines** both under separate labeled + sections in one call, instead of requiring two invocations to piece + together someone's full access picture. + +See [Skills/security-roles.md](../src/TALXIS.CLI.Features.Docs/Skills/security-roles.md) +for the full worked sequence (find role → find principal → assign/revoke). + +### `txc governance` — tenant-wide rules, no single-environment connection + +`governance` covers configuration that spans or organizes *multiple* +environments and has no single-connection framing: + +``` +txc governance environment-group list/get/create/update/delete, environment add/remove, role list/add/remove +txc governance policy-rule list/get/create/update/remove-rule, assign, assignment list +``` + +- **`environment-group`** is a tenant-level folder of managed + environments and the attachment point for both governance rules and + RBAC role assignments held directly on the group (not on any one member + environment). +- **`policy-rule`** is the modern rule-based-policy framework replacing + classic DLP policies. It targets the confirmed "Advanced Connector + Policy" rule type today (`--allow-connector` shorthand); other rule + types can be authored via `--rule-set-inputs-json` once their shapes are + confirmed by Microsoft. + +Both are genuinely new capabilities with no existing `environment`/ +`security` leaf to collide with, so no scope flag or naming exception is +needed for them. + +#### Confirmed API gaps, deliberately not worked around + +As of this writing, the Power Platform governance REST API does not +expose a delete-policy or an unassign/remove-assignment operation. `txc` +does **not** fake these with unsupported workarounds — `policy-rule`'s own +command descriptions and `IPowerPlatformPolicyRuleClient`'s XML docs both +call this out explicitly, along with the closest supported alternative +(excluding one environment from a group-wide assignment via +`--exclude-environment` on `assign`). Extend the interface once Microsoft +adds these operations; do not build a synthetic delete/unassign around +missing API support. + +## Why RBAC is the one exception to "organize by connection scope" + +Everywhere else in `txc`, the top-level split is: does this command need a +live connection to one specific org (`environment`), or is it tenant-wide +configuration (`governance`)? RBAC deliberately doesn't follow that split. +"Who has access, and to what" is one coherent question regardless of which +backing system (Dataverse or Entra/Power-Platform-admin) answers it — a +genuinely different shape of question from "what does this org's +schema/data/solution look like" (`environment`) or "which local files am I +editing" (`workspace`). Consolidating all RBAC under `security`, with scope +expressed as a flag rather than as a top-level location, matches that +mental model and removes a real discoverability problem: `environment` and +`security` no longer share a single leaf name (`user`, `service-principal`, +`team`, `role`) for conceptually different things. diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseRoleService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseRoleService.cs index 1cd051fc..642afe25 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseRoleService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseRoleService.cs @@ -12,7 +12,8 @@ public interface IDataverseRoleService Task> ListAsync( string? profileName, string? filter, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Resolves a single Dataverse role by role GUID or exact role name. @@ -23,5 +24,6 @@ Task> ListAsync( Task GetAsync( string? profileName, string nameOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); } diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseServicePrincipalService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseServicePrincipalService.cs index 647bd5cb..97986e73 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseServicePrincipalService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseServicePrincipalService.cs @@ -12,7 +12,8 @@ public interface IDataverseServicePrincipalService Task> ListAsync( string? profileName, DataverseSecurityPrincipalStateFilter filter, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Resolves a single Dataverse service principal by system-user GUID or @@ -23,7 +24,8 @@ Task> ListAsync( Task GetAsync( string? profileName, string clientIdOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Creates a Dataverse service principal directly in the environment and @@ -33,7 +35,8 @@ Task> ListAsync( Task CreateAsync( string? profileName, DataverseServicePrincipalCreateOptions options, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Enables or disables a Dataverse service principal resolved from a system @@ -44,7 +47,8 @@ Task UpdateEnabledStateAsync( string? profileName, string clientIdOrGuid, bool enabled, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Hard-deletes a Dataverse service principal. Dataverse only allows this @@ -54,7 +58,8 @@ Task UpdateEnabledStateAsync( Task DeleteAsync( string? profileName, string clientIdOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Lists security roles assigned to the resolved Dataverse application @@ -64,7 +69,8 @@ Task DeleteAsync( Task> ListRolesAsync( string? profileName, string clientIdOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Assigns a Dataverse security role to the resolved service principal. @@ -77,7 +83,8 @@ Task AddRoleAsync( string? profileName, string clientIdOrGuid, string roleNameOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Removes a Dataverse security role from the resolved service principal. @@ -90,5 +97,6 @@ Task RemoveRoleAsync( string? profileName, string clientIdOrGuid, string roleNameOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); } diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseTeamService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseTeamService.cs index 2d825e53..73a8225a 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseTeamService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseTeamService.cs @@ -10,7 +10,8 @@ public interface IDataverseTeamService /// Task> ListAsync( string? profileName, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Resolves a single Dataverse team by team GUID or exact team name. @@ -21,7 +22,8 @@ Task> ListAsync( Task GetAsync( string? profileName, string nameOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Creates a Dataverse team. When no business unit is supplied, the current @@ -31,7 +33,8 @@ Task> ListAsync( Task CreateAsync( string? profileName, DataverseTeamCreateOptions options, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Deletes a Dataverse team resolved from a GUID or exact team name. @@ -41,7 +44,8 @@ Task CreateAsync( Task DeleteAsync( string? profileName, string nameOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Lists Dataverse users that are direct members of an owner or access @@ -52,7 +56,8 @@ Task DeleteAsync( Task> ListMembersAsync( string? profileName, string teamIdOrName, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Adds a Dataverse environment user to an owner or access team. Throws a @@ -63,7 +68,8 @@ Task AddMemberAsync( string? profileName, string teamIdOrName, string userIdOrUpn, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Removes a Dataverse environment user from an owner or access team. @@ -74,7 +80,8 @@ Task RemoveMemberAsync( string? profileName, string teamIdOrName, string userIdOrUpn, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Lists security roles assigned to the resolved Dataverse team. Throws @@ -84,7 +91,8 @@ Task RemoveMemberAsync( Task> ListRolesAsync( string? profileName, string teamIdOrName, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Assigns a Dataverse security role to the resolved team. Both the team @@ -96,7 +104,8 @@ Task AddRoleAsync( string? profileName, string teamIdOrName, string roleNameOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Removes a Dataverse security role from the resolved team. Both the team @@ -108,5 +117,6 @@ Task RemoveRoleAsync( string? profileName, string teamIdOrName, string roleNameOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); } diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseUserService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseUserService.cs index 34a1614b..425f6f25 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseUserService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseUserService.cs @@ -12,7 +12,8 @@ public interface IDataverseUserService Task> ListAsync( string? profileName, DataverseSecurityPrincipalStateFilter filter, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Resolves a single Dataverse environment user by system-user GUID or UPN. @@ -23,7 +24,8 @@ Task> ListAsync( Task GetAsync( string? profileName, string userIdOrUpn, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Enables or disables a Dataverse environment user resolved from a GUID or @@ -34,7 +36,8 @@ Task UpdateEnabledStateAsync( string? profileName, string userIdOrUpn, bool enabled, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Lists security roles assigned to the resolved Dataverse environment @@ -44,7 +47,8 @@ Task UpdateEnabledStateAsync( Task> ListRolesAsync( string? profileName, string userIdOrUpn, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Assigns a Dataverse security role to the resolved environment user. @@ -56,7 +60,8 @@ Task AddRoleAsync( string? profileName, string userIdOrUpn, string roleNameOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Removes a Dataverse security role from the resolved environment user. @@ -68,5 +73,6 @@ Task RemoveRoleAsync( string? profileName, string userIdOrUpn, string roleNameOrGuid, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); } diff --git a/src/TALXIS.CLI.Core/Contracts/PowerPlatform/EnvironmentGroupContracts.cs b/src/TALXIS.CLI.Core/Contracts/PowerPlatform/EnvironmentGroupContracts.cs new file mode 100644 index 00000000..6e296123 --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/PowerPlatform/EnvironmentGroupContracts.cs @@ -0,0 +1,73 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Contracts.PowerPlatform; + +/// +/// A tenant-level "folder" that organizes managed environments and serves as +/// the attachment point for governance rules and role assignments. Mirrors +/// Microsoft's own "Environment groups" concept +/// (https://learn.microsoft.com/en-us/power-platform/admin/environment-groups). +/// +public sealed record PowerPlatformEnvironmentGroup( + Guid Id, + string DisplayName, + string? Description, + DateTimeOffset? CreatedOn, + Guid? CreatedByPrincipalObjectId, + DateTimeOffset? LastModifiedOn, + IReadOnlyList EnvironmentIds); + +/// +/// Fields accepted when creating a new environment group. +/// +public sealed record PowerPlatformEnvironmentGroupCreateOptions( + string DisplayName, + string? Description); + +/// +/// Fields accepted when updating an existing environment group. Only +/// non-null members are sent to the API (partial update). +/// +public sealed record PowerPlatformEnvironmentGroupUpdateOptions( + string? DisplayName, + string? Description); + +/// +/// Client abstraction over the environment-group management endpoints under +/// api.powerplatform.com/environmentmanagement/environmentGroups. +/// Membership operations (/ +/// ) are asynchronous on the service side +/// (202 Accepted); implementations poll the returned operation to +/// completion before returning. +/// +public interface IPowerPlatformEnvironmentGroupClient +{ + Task> ListAsync( + Connection connection, Credential credential, CancellationToken ct); + + Task GetAsync( + Connection connection, Credential credential, Guid environmentGroupId, CancellationToken ct); + + Task CreateAsync( + Connection connection, Credential credential, PowerPlatformEnvironmentGroupCreateOptions options, CancellationToken ct); + + Task UpdateAsync( + Connection connection, Credential credential, Guid environmentGroupId, PowerPlatformEnvironmentGroupUpdateOptions options, CancellationToken ct); + + /// + /// Deletes the environment group. The API returns 409 Conflict + /// when the group still has member environments or assigned policies; + /// callers wanting the CLI's --force cascading-delete behavior + /// should remove members/assignments first (see + /// envgroup-policy-force-delete follow-up work) rather than rely + /// on this method to cascade. + /// + Task DeleteAsync( + Connection connection, Credential credential, Guid environmentGroupId, CancellationToken ct); + + Task AddEnvironmentAsync( + Connection connection, Credential credential, Guid environmentGroupId, Guid environmentId, CancellationToken ct); + + Task RemoveEnvironmentAsync( + Connection connection, Credential credential, Guid environmentGroupId, Guid environmentId, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Contracts/PowerPlatform/IPowerPlatformRoleAssignmentStrategy.cs b/src/TALXIS.CLI.Core/Contracts/PowerPlatform/IPowerPlatformRoleAssignmentStrategy.cs index bc2dcbaf..0c1d497b 100644 --- a/src/TALXIS.CLI.Core/Contracts/PowerPlatform/IPowerPlatformRoleAssignmentStrategy.cs +++ b/src/TALXIS.CLI.Core/Contracts/PowerPlatform/IPowerPlatformRoleAssignmentStrategy.cs @@ -65,7 +65,7 @@ public sealed record PowerPlatformTenantRoleAssignment( /// Strategy abstraction for manipulating tenant-scoped role assignments. /// Concrete implementations handle either native Power Platform RBAC roles or /// synthetic/legacy role concepts such as admin-application. -/// uses +/// uses /// / to route work to /// the correct strategy instance without hardcoding per-strategy checks, so /// adding a new strategy (e.g. a future synthetic role) requires only a new diff --git a/src/TALXIS.CLI.Core/Contracts/PowerPlatform/PolicyRuleContracts.cs b/src/TALXIS.CLI.Core/Contracts/PowerPlatform/PolicyRuleContracts.cs new file mode 100644 index 00000000..016defaa --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/PowerPlatform/PolicyRuleContracts.cs @@ -0,0 +1,231 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Contracts.PowerPlatform; + +/// +/// A role assignment scoped to a single environment group (as opposed to the +/// whole tenant — see ). +/// Same wire shape as tenant/environment role assignments +/// (principalObjectId/principalType/roleDefinitionId/ +/// scope), scoped to +/// /environmentGroups/{environmentGroupId} instead of +/// /tenants/{tenantId}. +/// +public sealed record PowerPlatformEnvironmentGroupRoleAssignment( + string RoleAssignmentId, + Guid EnvironmentGroupId, + PowerPlatformPrincipalType PrincipalType, + Guid PrincipalObjectId, + Guid RoleDefinitionId, + DateTimeOffset? CreatedOn, + DateTimeOffset? ExpiresOn); + +/// +/// Client abstraction over environment-group role-assignment endpoints under +/// api.powerplatform.com/authorization/environmentGroups/{id}/roleAssignments +/// (Preview). +/// +public interface IPowerPlatformEnvironmentGroupRoleClient +{ + Task> ListAsync( + Connection connection, Credential credential, Guid environmentGroupId, CancellationToken ct); + + Task AddAsync( + Connection connection, Credential credential, Guid environmentGroupId, + PowerPlatformPrincipalType principalType, Guid principalObjectId, Guid roleDefinitionId, CancellationToken ct); + + Task RemoveAsync( + Connection connection, Credential credential, Guid environmentGroupId, string roleAssignmentId, CancellationToken ct); +} + +/// +/// The resource type a policy is assigned to or a per-resource override +/// targets. Confirmed from the official REST API reference +/// (PolicyAssignmentOverride.resourceType / +/// RuleAssignment.resourceType) — Tenant is accepted on +/// override requests but never observed on a RuleAssignment response. +/// +public enum PowerPlatformPolicyAssignmentResourceType +{ + NotSpecified = 0, + EnvironmentGroup = 1, + Environment = 2, + Tenant = 3, +} + +/// +/// How a per-resource assignment override behaves: whether the target +/// resource is explicitly included or excluded from the policy it would +/// otherwise inherit (e.g. excluding one environment from a +/// group-wide assignment). Confirmed enum values from the official REST +/// API reference (PolicyAssignmentOverride.behaviorType). +/// +public enum PowerPlatformPolicyBehaviorType +{ + NotSpecified = 0, + Include = 1, + Exclude = 2, +} + +/// +/// A per-resource override supplied when assigning a policy to an +/// environment group or environment (e.g. "assign to this group, but +/// exclude this one member environment"). +/// +public sealed record PowerPlatformPolicyAssignmentOverride( + PowerPlatformPolicyBehaviorType BehaviorType, + Guid ResourceId, + PowerPlatformPolicyAssignmentResourceType ResourceType); + +/// +/// One rule set within a policy. Id is a type discriminator (the only +/// confirmed value as of this writing is "ConnectorManagement" for the +/// Advanced Connector Policy rule type — see +/// ). InputsJson +/// is kept as raw JSON rather than a closed DTO because the inputs +/// shape varies per rule type and only one rule type's shape is confirmed +/// so far; use to +/// build/parse it for ConnectorManagement rule sets. +/// +public sealed record PowerPlatformPolicyRuleSet( + string Id, + string Version, + string InputsJson) +{ + /// The only confirmed rule set type: Advanced Connector Policy. + public const string ConnectorManagementRuleSetId = "ConnectorManagement"; +} + +/// +/// One connector's allow-list entry within an Advanced Connector Policy +/// () +/// rule set's inputs.AllowedConnectorList. Connectors NOT present in +/// the list are blocked by default (default-deny). Field names/casing and +/// enum values confirmed from the official Microsoft "Advanced Connector +/// Policy programmability" tutorial. +/// +public sealed record PowerPlatformAllowedConnectorRule( + [property: System.Text.Json.Serialization.JsonPropertyName("AllowedConnector")] string AllowedConnector, + [property: System.Text.Json.Serialization.JsonPropertyName("AllowedActionsMode")] string AllowedActionsMode, + [property: System.Text.Json.Serialization.JsonPropertyName("AllowedActions")] IReadOnlyList? AllowedActions, + [property: System.Text.Json.Serialization.JsonPropertyName("AllowedConnectionTypesMode")] string AllowedConnectionTypesMode) +{ + /// All actions and connection types on this connector are allowed. + public const string AllAllowedMode = "AllAllowed"; + + /// Only the actions listed in are allowed. + public const string SomeAllowedMode = "SomeAllowed"; +} + +/// +/// Strongly-typed helper for building/parsing the inputs JSON of a +/// rule +/// set — the only rule set type whose inputs shape is confirmed as of +/// this writing (from the official ACP programmability tutorial). Other +/// rule set types must be authored via raw +/// until their shapes are confirmed. +/// +public sealed record PowerPlatformAdvancedConnectorPolicyInputs( + [property: System.Text.Json.Serialization.JsonPropertyName("AllowedConnectorList")] IReadOnlyList AllowedConnectorList) +{ + public string ToInputsJson() => System.Text.Json.JsonSerializer.Serialize(this); + + public static PowerPlatformAdvancedConnectorPolicyInputs FromInputsJson(string json) + => System.Text.Json.JsonSerializer.Deserialize(json) + ?? throw new ArgumentException("Could not parse Advanced Connector Policy inputs JSON.", nameof(json)); +} + +/// +/// A single rule-based policy — the modern governance/policy framework that +/// is replacing classic DLP policies. Confirmed shape from +/// api.powerplatform.com/governance/ruleBasedPolicies (api-version +/// 2024-10-01). +/// +public sealed record PowerPlatformPolicy( + Guid Id, + string Name, + string? TenantId, + DateTimeOffset? LastModified, + int RuleSetCount, + IReadOnlyList RuleSets); + +/// Fields accepted when creating a rule-based policy. +public sealed record PowerPlatformPolicyCreateOptions( + string Name, + IReadOnlyList RuleSets); + +/// +/// Fields accepted by the PATCH "add or update rule sets" operation. Unlike +/// , is +/// additive/merging server-side (existing rule sets not present in this list +/// are left untouched) — there is no "full replace" (PUT) operation exposed +/// by this client because the PATCH semantics cover every supported CLI +/// workflow without risking accidental deletion of unrelated rule sets. +/// +public sealed record PowerPlatformPolicyPatchOptions( + string? Name, + IReadOnlyList? RuleSets); + +/// +/// Records that a policy has been assigned to an environment group or a +/// single environment. +/// +public sealed record PowerPlatformPolicyAssignment( + Guid PolicyId, + Guid ResourceId, + PowerPlatformPolicyAssignmentResourceType ResourceType, + int RuleSetCount, + string? TenantId); + +/// +/// Client abstraction over the rule-based-policy CRUD and assignment +/// endpoints under api.powerplatform.com/governance/ruleBasedPolicies +/// (api-version 2024-10-01, confirmed from the official REST API reference). +/// +/// +/// Two operations Microsoft's own pac-equivalent tooling would +/// normally offer are deliberately NOT part of this interface because the +/// confirmed API surface (as of this writing) does not expose them: +/// deleting a policy, and removing/unassigning a policy assignment. Only +/// (removing one rule set from a policy) is +/// supported. If Microsoft adds these operations, extend this interface +/// then — do not fake them with unsupported workarounds. +/// +public interface IPowerPlatformPolicyRuleClient +{ + Task> ListAsync( + Connection connection, Credential credential, CancellationToken ct); + + Task GetAsync( + Connection connection, Credential credential, Guid policyId, CancellationToken ct); + + Task CreateAsync( + Connection connection, Credential credential, PowerPlatformPolicyCreateOptions options, CancellationToken ct); + + /// Adds or updates one or more rule sets on an existing policy (and/or renames it). + Task UpdateAsync( + Connection connection, Credential credential, Guid policyId, PowerPlatformPolicyPatchOptions options, CancellationToken ct); + + /// Removes a single rule set (identified by its rule set id, e.g. "ConnectorManagement") from a policy. + Task RemoveRuleSetAsync( + Connection connection, Credential credential, Guid policyId, string ruleSetId, CancellationToken ct); + + Task AssignToEnvironmentGroupAsync( + Connection connection, Credential credential, Guid policyId, Guid environmentGroupId, + IReadOnlyList? overrides, CancellationToken ct); + + Task AssignToEnvironmentAsync( + Connection connection, Credential credential, Guid policyId, Guid environmentId, + IReadOnlyList? overrides, CancellationToken ct); + + /// + /// Lists policy assignments, optionally filtered to exactly one + /// dimension (policy, environment group, or environment). Pass all + /// nulls to list every assignment in the tenant. Specifying more than + /// one filter is a caller error (throws ) + /// since the underlying API exposes one endpoint per filter dimension. + /// + Task> ListAssignmentsAsync( + Connection connection, Credential credential, + Guid? policyId, Guid? environmentGroupId, Guid? environmentId, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentUserProvisioningService.cs b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentUserProvisioningService.cs index 11208b59..66d93bd4 100644 --- a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentUserProvisioningService.cs +++ b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IEnvironmentUserProvisioningService.cs @@ -15,7 +15,7 @@ public sealed record EnvironmentUserProvisionResult( /// Provisions a brand-new Entra user into a Dataverse environment so they can /// be assigned security roles immediately, without waiting for the user to /// sign in once and be picked up by background JIT sync. Backs -/// txc environment user add. +/// txc security user add --environment .... /// public interface IEnvironmentUserProvisioningService { @@ -28,12 +28,13 @@ public interface IEnvironmentUserProvisioningService Task ProvisionUserAsync( string? profileName, string userIdOrUpn, - CancellationToken ct); + CancellationToken ct, + Guid? environmentId = null); /// /// Applies the environment admin role to the current authenticated /// caller (/) - /// in the given environment. Backs txc environment user self-elevate. + /// in the given environment. Backs txc security user self-elevate. /// Task SelfElevateAsync( Connection connection, diff --git a/src/TALXIS.CLI.Features.Docs/Skills/governance-workflow.md b/src/TALXIS.CLI.Features.Docs/Skills/governance-workflow.md new file mode 100644 index 00000000..fa88108b --- /dev/null +++ b/src/TALXIS.CLI.Features.Docs/Skills/governance-workflow.md @@ -0,0 +1,99 @@ +# Governance: Environment Groups & Rule-Based Policies + +## Scope + +`txc governance` manages tenant-wide governance configuration that spans +or organizes multiple environments — as opposed to `txc security`, which +manages identity/access (RBAC) for the tenant or for one connected +Dataverse environment. See `docs/command-taxonomy.md` in the repository +root for the full rationale. + +Classic DLP policies are out of scope — Microsoft is replacing them with +the rule-based-policy framework this doc covers, so `txc` targets only the +modern framework. + +## End-to-End Workflow + +The typical governance sequence, in order: + +1. **Create an environment group** — a tenant-level folder that organizes + managed environments and serves as the attachment point for both role + assignments and policy rules. + ```sh + txc governance environment-group create --display-name "Finance environments" [--description "..."] + ``` +2. **Add member environments to the group.** Only managed environments can + belong to a group; each environment can belong to at most one group at + a time. The environment immediately inherits every rule already + published on the group. + ```sh + txc governance environment-group environment add --environment + ``` +3. **Grant access to the group** via RBAC role assignments held directly on + it — these apply to every current and future member environment. + ```sh + txc governance environment-group role add --principal-type User --principal --role Contributor + txc governance environment-group role list + ``` +4. **Create a rule-based policy.** The only confirmed rule type today is + the Advanced Connector Policy (`ConnectorManagement`), an allow-list of + connectors — connectors not listed are blocked by default. + ```sh + # Allow every action on Office 365, and only two actions on SQL: + txc governance policy-rule create --name "Finance connector policy" \ + --allow-connector shared_office365 \ + --allow-connector "shared_sql=ExecuteProcedure,GetRows" + ``` +5. **Assign the policy** to the environment group (or a single + environment). Assigning to a group applies it to every current and + future member; use `--exclude-environment` (repeatable) to exempt + specific members from a group-wide assignment. + ```sh + txc governance policy-rule assign --environment-group + txc governance policy-rule assign --environment-group --exclude-environment + txc governance policy-rule assign --environment + txc governance policy-rule assignment list [--policy | --environment-group | --environment ] + ``` + +## Adding or Updating Rule Sets on an Existing Policy + +`update` is additive — existing rule sets not targeted by the call are +left untouched: + +```sh +# Rename the policy and/or add/replace one rule set: +txc governance policy-rule update --name "New name" --allow-connector shared_office365 + +# Remove one rule set entirely (the policy itself is not deleted): +txc governance policy-rule remove-rule --rule-set-id ConnectorManagement +``` + +For rule types other than `ConnectorManagement`, author the `inputs` JSON +directly once its shape is confirmed, instead of `--allow-connector`: + +```sh +txc governance policy-rule create --name "..." --rule-set-id --rule-set-inputs-json '{...}' +``` + +## Confirmed API Gaps (Not Worked Around) + +As of this writing, the Power Platform governance REST API does not +expose: + +- **Deleting a policy.** Nothing to work around — leave unused policies + unassigned instead. +- **Unassigning a policy from a resource.** To stop enforcing a group-wide + policy on one member environment, reassign the policy to the group with + that environment added to `--exclude-environment` instead of unassigning + it outright. + +`policy-rule`'s own command descriptions and `IPowerPlatformPolicyRuleClient` +document this explicitly. `txc` does not fake these operations with +unsupported workarounds — extend the client once Microsoft adds them. + +## Deleting an Environment Group + +Deletion is rejected (`409 Conflict`) while the group still has member +environments or assigned policy rules. Remove members +(`environment-group environment remove`) first; there is currently no +`--force` cascading delete (tracked as a follow-up). diff --git a/src/TALXIS.CLI.Features.Docs/Skills/security-roles.md b/src/TALXIS.CLI.Features.Docs/Skills/security-roles.md index bae0fe5e..937a97c8 100644 --- a/src/TALXIS.CLI.Features.Docs/Skills/security-roles.md +++ b/src/TALXIS.CLI.Features.Docs/Skills/security-roles.md @@ -6,6 +6,23 @@ 2. **Add privileges** — `pp-security-role-privilege` template to grant table-level access. 3. **Assign to app** — `pp-app-security-role` template to bind the role to a model-driven app. +## Scope Resolution for `txc security ... --environment` + +For `txc security user`, `txc security service-principal`, `txc security role`, and `txc security team`, the `--environment ` flag always means the same thing: run the Dataverse environment-scoped implementation for that environment instead of the tenant-wide implementation. + +Resolution order: + +1. If `--environment ` is passed, `txc` uses that Dataverse environment. +2. Otherwise, if the resolved profile is already connected to an environment, `txc` uses that active environment. +3. Otherwise, `txc` falls back to the tenant-wide Entra / Power Platform admin implementation when that command supports one. + +`txc security team ...` has no tenant-wide fallback. It always requires `--environment` or an active environment connection. + +## Catalog vs Assignment Lists + +- `txc security role list [--environment ]` switches catalogs. Without an environment it lists tenant admin roles; with an environment it lists that environment's Dataverse security-role catalog. The catalogs are never combined. +- `txc security user role list ... [--environment ]` and `txc security service-principal role list ... [--environment ]` list assignments. Without an environment they show tenant admin roles only. With an environment they show **both** tenant admin roles and that environment's Dataverse security-role assignments under separate labeled sections. + ## Privilege Types | Privilege | Purpose | @@ -46,86 +63,33 @@ When using `pp-security-role-privilege`, specify privileges as a JSON array: ``` Each entry maps a privilege type to the desired depth. Omitted types default to `None`. -## Assigning Roles to Environment Users, Service Principals, and Teams - -Once a security role exists (scaffolded above, or already present in the target environment), -use these commands to find it and assign it to whoever needs it. All three principal kinds -share the same shape: `list`/`get` to find the principal, `role list/add/remove` to manage its -roles. - -1. **Find the role** — `txc environment role list [--filter ]` / - `txc environment role get --role `. This is a read-only browse of the roles - already defined in the target environment (via the scaffolding chain above or Dataverse's - built-in roles) — accepts either the role name or its GUID everywhere `--role` is used below. -2. **Find the principal:** - - Regular (human) user: `txc environment user list [--enabled|--disabled|--all]` / - `txc environment user get --user `. Regular users are normally provisioned by - background Entra sync the first time they sign in, so `list`/`get`/`update` (enable/disable) - is usually all you need. If the user has never signed in to this environment yet and you - need to assign a role before that happens, use `txc environment user add --user - [--role [,,...]]` to provision access - immediately — safe to run again for a user who already has access. To grant *yourself* - admin access when you have none at all in this environment, use `txc environment user - self-elevate` instead (a different, tenant-admin-privileged bootstrap path — see its own - help text). - - Service principal: `txc environment service-principal list [--enabled|--disabled|--all]` - / `txc environment service-principal get --service-principal `. If the service principal doesn't - exist yet, create it directly (the Entra app registration itself must already exist): - `txc environment service-principal create --service-principal [--business-unit ] - [--role [,,...]]` — `--role` accepts a comma-separated list to - assign initial roles in the same step. - - Team: `txc environment team list` / `txc environment team get --team `. If it - doesn't exist yet: `txc environment team create --name --type - owner|access|aad-security-group|aad-office-group [--aad-object-id ] - [--membership-type <..>] [--business-unit ]`, then (for `owner`/`access` teams - only — AAD-backed team membership is managed in Entra ID) `txc environment team member add - --team --user `. -3. **Assign or revoke the role:** - - `txc environment user role add --user --role ` / - `txc environment user role remove --user .. --role ..` - - `txc environment service-principal role add --service-principal --role ` / - `txc environment service-principal role remove --service-principal .. --role ..` - - `txc environment team role add --team --role ` / - `txc environment team role remove --team .. --role ..` - - Use `role list --user|--service-principal|--team ..` at any point to see currently assigned roles. - -## Tenant-Wide Admin Roles (`txc tenant ...`) - -The commands above manage access **within one environment**. A separate, tenant-wide set of -commands manages admin-level access **across the whole tenant** — e.g. who can administer -Power Platform environments, connectors, or DLP policies tenant-wide. These commands never -create or modify anything in Entra ID — they only discover principals that already exist there -and manage their tenant-wide admin roles. - -1. **Find the tenant role** — `txc tenant role list [--filter ]` / - `txc tenant role get --role `. This is the catalog every `--role` value below - is validated against. -2. **Find the principal:** - - `txc tenant service-principal list [--filter ]` / `txc tenant service-principal get --service-principal ` - - `txc tenant user list [--filter ]` / `txc tenant user get --user ` - - Groups have no `list`/`get` — see the note below. -3. **Assign or revoke the tenant role:** - - `txc tenant service-principal role add --service-principal .. --role ` / `role remove --service-principal .. --role ..` - - `txc tenant user role add --user .. --role ` / `role remove --user .. --role ..` - - `txc tenant group role add --group --role ` / - `role remove --group --role ..`. Unlike service principals and users, `--group` must be the - group's Entra **object id (GUID)** — this CLI never looks groups up by display name, because - that would require the Microsoft Graph `Group.Read.All` permission, which is not - pre-consented for this CLI's Entra app registration in most tenants. This CLI never prompts - for extra consent, so find the object id yourself first, e.g. via the Entra admin center or - `az ad group show --group --query id -o tsv`. - - For applications only, `--role admin-application` is a special value: it authorizes that - app to call this CLI's own `environment` admin commands (`create`/`list`/`update`/`delete`) - non-interactively, modeled as just another role the app can hold — not a separate - create/delete concept. Unlike every other role, this one is **not** a Power Platform RBAC - role assignment: it is implemented by registering the application with the Power Platform - Admin (BAP) API's `adminApplications` endpoint, the same mechanism Microsoft documents at - [Create a service principal to create and manage environments and other resources for - Power Platform](https://learn.microsoft.com/en-us/power-platform/admin/powerplatform-api-create-service-principal). - Because it uses a different API, `role list` marks it with `"isSynthetic": true` in its - output (every real RBAC role assignment shows `"isSynthetic": false`) — this is how a - consumer scripting against the JSON output can tell the two apart. Attempting - `--role admin-application` against a `--user` or `--group` fails with a validation error, - since only applications can be registered as environment-management service principals. - - Use `role list --app|--user|--group ..` at any point to see currently assigned tenant roles. - +## Assigning Roles to Users, Service Principals, and Teams + +1. **Find the role** + - Tenant catalog: `txc security role list [--filter ]`, `txc security role get --role ` + - Dataverse catalog: `txc security role list --environment [--filter ]`, `txc security role get --environment --role ` +2. **Find the principal** + - User (tenant): `txc security user list [--filter ]`, `txc security user get --user ` + - User (Dataverse): `txc security user list --environment [--enabled|--disabled|--all]`, `txc security user get --environment --user ` + - Provision a Dataverse user immediately: `txc security user add --environment --user [--role [,,...]]` + - Bootstrap yourself into a Dataverse environment: `txc security user self-elevate --environment ` + - Service principal (tenant): `txc security service-principal list [--filter ]`, `txc security service-principal get --service-principal ` + - Service principal (Dataverse): `txc security service-principal list --environment [--enabled|--disabled|--all]`, `txc security service-principal get --environment --service-principal ` + - Create a Dataverse service principal: `txc security service-principal create --environment --service-principal [--business-unit ] [--role [,,...]]` + - Team (Dataverse only): `txc security team list --environment `, `txc security team get --environment --team `, `txc security team create --environment --name --type owner|access|aad-security-group|aad-office-group [--aad-object-id ] [--membership-type <..>] [--business-unit ]` +3. **Assign or revoke the role** + - Tenant user roles: `txc security user role add --user --role ` / `txc security user role remove --user .. --role ..` + - Dataverse user roles: `txc security user role add --environment --user --role ` / `txc security user role remove --environment --user .. --role ..` + - Tenant service-principal roles: `txc security service-principal role add --service-principal --role ` / `txc security service-principal role remove --service-principal .. --role ..` + - Dataverse service-principal roles: `txc security service-principal role add --environment --service-principal --role ` / `txc security service-principal role remove --environment --service-principal .. --role ..` + - Dataverse team roles: `txc security team role add --environment --team --role ` / `txc security team role remove --environment --team .. --role ..` + +## Tenant-Wide Admin Roles (`txc security ...` without `--environment`) + +These commands manage admin-level access across the whole tenant. They never create or modify anything in Entra ID — they only discover principals that already exist there and manage their tenant-wide admin roles. + +- `txc security service-principal role add/remove ...` +- `txc security user role add/remove ...` +- `txc security group role add/remove ...` + +For applications only, `--role admin-application` is a special value: it authorizes that app to call `txc` environment admin commands (`create`/`list`/`update`/`delete`) non-interactively. Unlike every other role, this one is implemented through the Power Platform Admin (BAP) API's `adminApplications` endpoint, so `role list` marks it with `"isSynthetic": true`. diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index dc28b13f..3e26fc7f 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand), typeof(User.UserCliCommand), typeof(ServicePrincipal.ServicePrincipalCliCommand), typeof(Team.TeamCliCommand), typeof(Role.RoleCliCommand) }, + Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentPrincipalCommandSupport.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentPrincipalCommandSupport.cs deleted file mode 100644 index de4b9926..00000000 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentPrincipalCommandSupport.cs +++ /dev/null @@ -1,125 +0,0 @@ -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core.Contracts.Dataverse; - -namespace TALXIS.CLI.Features.Environment; - -/// -/// Shared command-support helpers reused by the txc environment user, -/// txc environment service-principal, and txc environment team command -/// groups, all of which manage Dataverse security principals within an -/// environment. Mirrors TenantPrincipalCommandSupport on the tenant -/// side. -/// -internal static class EnvironmentPrincipalCommandSupport -{ - /// - /// Resolves the mutually-exclusive --enabled/--disabled/--all - /// list filter options shared by environment user list and - /// environment service-principal list. - /// - internal static bool TryResolveStateFilter( - bool enabled, - bool disabled, - bool all, - ILogger logger, - out DataverseSecurityPrincipalStateFilter filter) - { - var selected = (enabled ? 1 : 0) + (disabled ? 1 : 0) + (all ? 1 : 0); - if (selected > 1) - { - logger.LogError("Specify at most one of --enabled, --disabled, or --all."); - filter = default; - return false; - } - - filter = disabled - ? DataverseSecurityPrincipalStateFilter.Disabled - : all - ? DataverseSecurityPrincipalStateFilter.All - : DataverseSecurityPrincipalStateFilter.Enabled; - return true; - } - - /// - /// Parses a comma-separated --role option value into a - /// deduplicated list of role names/GUIDs, shared by the environment user - /// and service-principal "create with roles" commands. - /// - internal static bool TryParseRoleIdentifiers( - string? csv, - ILogger logger, - out IReadOnlyList roles) - { - if (string.IsNullOrWhiteSpace(csv)) - { - roles = Array.Empty(); - return true; - } - - var parsed = csv - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (parsed.Length == 0) - { - logger.LogError("--role must contain at least one role name or GUID when specified."); - roles = Array.Empty(); - return false; - } - - roles = parsed; - return true; - } - - /// - /// Truncates a display value to fit within a fixed-width table column, - /// appending a trailing "." marker when truncation occurs. - /// - internal static string Truncate(string value, int maxWidth) - => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; - - /// - /// Matches an already-assigned role against a caller-supplied - /// --role identifier, which may be either the role's GUID or its - /// friendly name. Shared by the role add commands for - /// environment user, environment service-principal, and - /// environment team to consistently detect a no-op re-assignment. - /// - internal static bool IsRoleMatch(DataverseRoleRecord role, string roleNameOrGuid) - => string.Equals(role.Id.ToString(), roleNameOrGuid, StringComparison.OrdinalIgnoreCase) - || string.Equals(role.Name, roleNameOrGuid, StringComparison.OrdinalIgnoreCase); - - /// - /// Shared exit-code mapping for the validation-style exceptions raised by - /// Dataverse security-principal resolution/mutation (ambiguous friendly-name - /// match, invalid argument, invalid operation). Callers supply a - /// delegate so each command group can - /// keep its own candidate-listing format (e.g. environment user logs a - /// bulleted list, environment service-principal logs a single "Candidate:" line per - /// match) while sharing the exception-type dispatch and exit-code contract. - /// - internal static bool TryHandleValidationException( - ILogger logger, - Exception ex, - Action logAmbiguousMatch, - out int exitCode) - { - if (ex is DataverseAmbiguousMatchException ambiguous) - { - logAmbiguousMatch(logger, ambiguous); - exitCode = 2; - return true; - } - - if (ex is ArgumentException or InvalidOperationException) - { - logger.LogError("{Error}", ex.Message); - exitCode = 2; - return true; - } - - exitCode = 0; - return false; - } -} diff --git a/src/TALXIS.CLI.Features.Environment/Role/RoleCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Role/RoleCliCommand.cs deleted file mode 100644 index 2a3b1988..00000000 --- a/src/TALXIS.CLI.Features.Environment/Role/RoleCliCommand.cs +++ /dev/null @@ -1,23 +0,0 @@ -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Environment.Role; - -/// -/// Parent command for browsing Dataverse security roles. -/// Usage: txc environment role [list|get] -/// The output helps you find values to pass to --role on other -/// txc environment commands. -/// -[CliCommand( - Name = "role", - Description = "Browse Dataverse security roles in the target environment.", - Children = new[] { typeof(RoleListCliCommand), typeof(RoleGetCliCommand) }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class RoleCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} diff --git a/src/TALXIS.CLI.Features.Environment/Role/RoleGetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Role/RoleGetCliCommand.cs deleted file mode 100644 index 47932ae3..00000000 --- a/src/TALXIS.CLI.Features.Environment/Role/RoleGetCliCommand.cs +++ /dev/null @@ -1,78 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.Role; - -/// -/// Gets one Dataverse security role by role name or GUID so you can confirm the -/// exact value to pass to --role on other txc environment commands. -/// Usage: txc environment role get --role <name-or-guid> -/// -[CliReadOnly] -[CliCommand( - Name = "get", - Description = "Get a Dataverse security role by role name or GUID." -)] -#pragma warning disable TXC003 -public class RoleGetCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(RoleGetCliCommand)); - - [CliOption(Name = "--role", Description = "Role name or GUID to resolve for use with other --role options.", Required = true)] - public string Role { get; set; } = null!; - - protected override Task ExecuteAsync() - { - if (string.IsNullOrWhiteSpace(Role)) - { - Logger.LogError("Specify --role with a role name or GUID."); - return Task.FromResult(ExitValidationError); - } - - return ExecuteGetRoleAsync(Role.Trim()); - } - - private async Task ExecuteGetRoleAsync(string role) - { - var service = TxcServices.Get(); - - try - { - var row = await service.GetAsync(Profile, role, CancellationToken.None).ConfigureAwait(false); - if (row is null) - { - Logger.LogError("Role '{Role}' not found.", role); - return ExitValidationError; - } - - OutputFormatter.WriteData(row, PrintRole); - return ExitSuccess; - } - catch (DataverseAmbiguousMatchException ex) - { - Logger.LogError("Multiple roles matched '{Role}'. Specify the role GUID instead.", role); - foreach (var candidate in ex.Candidates) - { - Logger.LogError( - "Candidate: {Name} | Business Unit: {BusinessUnit} | Id: {Id}", - candidate.Name, - candidate.Description ?? "-", - candidate.Id); - } - - return ExitValidationError; - } - } - - private static void PrintRole(DataverseRoleRecord role) - { - OutputWriter.WriteLine($"Name: {role.Name}"); - OutputWriter.WriteLine($"Business Unit: {role.BusinessUnitName ?? "-"}"); - OutputWriter.WriteLine($"Id: {role.Id}"); - } -} -#pragma warning restore TXC003 diff --git a/src/TALXIS.CLI.Features.Environment/Role/RoleListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Role/RoleListCliCommand.cs deleted file mode 100644 index a47a2abd..00000000 --- a/src/TALXIS.CLI.Features.Environment/Role/RoleListCliCommand.cs +++ /dev/null @@ -1,71 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.Role; - -/// -/// Lists Dataverse security roles so you can find a role name or GUID to use -/// with the --role option on other txc environment commands. -/// Usage: txc environment role list [--filter <name>] -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List Dataverse security roles in the target environment." -)] -#pragma warning disable TXC003 -public class RoleListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(RoleListCliCommand)); - - [CliOption(Name = "--filter", Description = "Optional role name contains-filter to help find a role for --role.", Required = false)] - public string? Filter { get; set; } - - protected override async Task ExecuteAsync() - { - var service = TxcServices.Get(); - var rows = await service.ListAsync(Profile, NormalizeFilter(Filter), CancellationToken.None).ConfigureAwait(false); - - OutputFormatter.WriteList(rows, PrintRolesTable); - return ExitSuccess; - } - - private static void PrintRolesTable(IReadOnlyList rows) - { - if (rows.Count == 0) - { - OutputWriter.WriteLine("No roles found."); - return; - } - - int nameWidth = Math.Clamp(rows.Max(r => r.Name.Length), 4, 50); - int businessUnitWidth = Math.Clamp(rows.Max(r => (r.BusinessUnitName ?? "").Length), 13, 40); - int idWidth = 36; - - string header = - $"{"Name".PadRight(nameWidth)} | " + - $"{"Business Unit".PadRight(businessUnitWidth)} | " + - $"{"Id".PadRight(idWidth)}"; - OutputWriter.WriteLine(header); - OutputWriter.WriteLine(new string('-', header.Length)); - - foreach (var row in rows) - { - OutputWriter.WriteLine( - $"{Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + - $"{Truncate(row.BusinessUnitName ?? "-", businessUnitWidth).PadRight(businessUnitWidth)} | " + - $"{row.Id}"); - } - } - - private static string? NormalizeFilter(string? value) - => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - - private static string Truncate(string value, int maxWidth) - => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; -} -#pragma warning restore TXC003 diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalCommandSupport.cs b/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalCommandSupport.cs deleted file mode 100644 index c0e790d5..00000000 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalCommandSupport.cs +++ /dev/null @@ -1,184 +0,0 @@ -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; - -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; - -internal static class ServicePrincipalCommandSupport -{ - internal static bool TryResolveStateFilter( - bool enabled, - bool disabled, - bool all, - ILogger logger, - out DataverseSecurityPrincipalStateFilter filter) - => EnvironmentPrincipalCommandSupport.TryResolveStateFilter(enabled, disabled, all, logger, out filter); - - internal static bool TryResolveEnabledState( - bool enable, - bool disable, - ILogger logger, - out bool enabled) - { - if (enable == disable) - { - logger.LogError("Specify exactly one of --enable or --disable."); - enabled = false; - return false; - } - - enabled = enable; - return true; - } - - internal static bool TryParseRoleIdentifiers( - string? csv, - ILogger logger, - out IReadOnlyList roles) - => EnvironmentPrincipalCommandSupport.TryParseRoleIdentifiers(csv, logger, out roles); - - internal static bool TryHandleValidationException(ILogger logger, Exception ex, out int exitCode) - => EnvironmentPrincipalCommandSupport.TryHandleValidationException(logger, ex, LogAmbiguousMatch, out exitCode); - - private static void LogAmbiguousMatch(ILogger logger, DataverseAmbiguousMatchException ambiguous) - { - logger.LogError("{Error}", ambiguous.Message); - foreach (var candidate in ambiguous.Candidates) - { - logger.LogError( - "Candidate: {Name} ({Id}){Description}", - candidate.Name, - candidate.Id, - string.IsNullOrWhiteSpace(candidate.Description) - ? string.Empty - : $" — {candidate.Description}"); - } - } - - internal static void WriteServicePrincipalDetails(DataverseServicePrincipalRecord app) - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"System User ID: {app.Id}"); - OutputWriter.WriteLine($"Application ID: {app.ApplicationId}"); - OutputWriter.WriteLine($"Name: {app.FullName ?? "-"}"); - OutputWriter.WriteLine($"State: {(app.IsDisabled ? "Disabled" : "Enabled")}"); - OutputWriter.WriteLine($"Business Unit: {app.BusinessUnitName ?? "-"}"); - OutputWriter.WriteLine($"Business Unit ID: {(app.BusinessUnitId?.ToString() ?? "-")}"); - OutputWriter.WriteLine($"Entra Object ID: {(app.AzureActiveDirectoryObjectId?.ToString() ?? "-")}"); -#pragma warning restore TXC003 - } - - internal static void WriteAppTable(IReadOnlyList rows) - { -#pragma warning disable TXC003 - if (rows.Count == 0) - { - OutputWriter.WriteLine("No service principals found."); - return; - } - - int nameWidth = Math.Clamp(rows.Max(static r => (r.FullName ?? string.Empty).Length), 4, 36); - int stateWidth = 8; - int businessUnitWidth = Math.Clamp(rows.Max(static r => (r.BusinessUnitName ?? string.Empty).Length), 13, 36); - - string header = - $"{"System User ID".PadRight(36)} | " + - $"{"Application ID".PadRight(36)} | " + - $"{"Name".PadRight(nameWidth)} | " + - $"{"State".PadRight(stateWidth)} | " + - $"{"Business Unit".PadRight(businessUnitWidth)}"; - OutputWriter.WriteLine(header); - OutputWriter.WriteLine(new string('-', header.Length)); - - foreach (var row in rows) - { - OutputWriter.WriteLine( - $"{row.Id} | " + - $"{row.ApplicationId} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.FullName ?? string.Empty, nameWidth).PadRight(nameWidth)} | " + - $"{(row.IsDisabled ? "Disabled" : "Enabled").PadRight(stateWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? string.Empty, businessUnitWidth).PadRight(businessUnitWidth)}"); - } -#pragma warning restore TXC003 - } - - internal static void WriteRoleTable(IReadOnlyList rows) - { -#pragma warning disable TXC003 - if (rows.Count == 0) - { - OutputWriter.WriteLine("No security roles assigned."); - return; - } - - int nameWidth = Math.Clamp(rows.Max(static r => r.Name.Length), 4, 48); - int businessUnitWidth = Math.Clamp(rows.Max(static r => (r.BusinessUnitName ?? string.Empty).Length), 13, 36); - - string header = - $"{"Role ID".PadRight(36)} | " + - $"{"Name".PadRight(nameWidth)} | " + - $"{"Business Unit".PadRight(businessUnitWidth)}"; - OutputWriter.WriteLine(header); - OutputWriter.WriteLine(new string('-', header.Length)); - - foreach (var row in rows) - { - OutputWriter.WriteLine( - $"{row.Id} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? string.Empty, businessUnitWidth).PadRight(businessUnitWidth)}"); - } -#pragma warning restore TXC003 - } - - internal static void WriteCreateResult( - DataverseServicePrincipalRecord app, - IReadOnlyList assignedRoles, - IReadOnlyList failures) - { - var payload = new - { - status = failures.Count == 0 ? "created" : "partial", - servicePrincipal = app, - assignedRoles, - failedRoles = failures.Select(static failure => new - { - role = failure.Role, - error = failure.Message, - }).ToArray(), - }; - - OutputFormatter.WriteData(payload, _ => - { -#pragma warning disable TXC003 - OutputWriter.WriteLine(failures.Count == 0 - ? "Service principal created." - : "Service principal created, but one or more role assignments failed."); - WriteServicePrincipalDetails(app); - - if (assignedRoles.Count > 0) - { - OutputWriter.WriteLine(); - OutputWriter.WriteLine($"Assigned roles ({assignedRoles.Count}):"); - foreach (var role in assignedRoles) - OutputWriter.WriteLine($" - {role}"); - } - - if (failures.Count > 0) - { - OutputWriter.WriteLine(); - OutputWriter.WriteLine($"Role assignment failures ({failures.Count}):"); - foreach (var failure in failures) - OutputWriter.WriteLine($" - {failure.Role}: {failure.Message}"); - } -#pragma warning restore TXC003 - }); - } - - internal static void WriteMutationResult(T payload, Action textRenderer) - { - OutputFormatter.WriteData(payload, _ => textRenderer()); - } -} - -internal sealed record ServicePrincipalRoleAssignmentFailure(string Role, string Message, bool IsValidationError); diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalGetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalGetCliCommand.cs deleted file mode 100644 index 74366d02..00000000 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalGetCliCommand.cs +++ /dev/null @@ -1,48 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; - -/// -/// Gets one Dataverse service principal by system-user GUID or application client ID. -/// Usage: txc environment service-principal get --service-principal <client-id-or-guid> -/// -[CliReadOnly] -[CliCommand( - Name = "get", - Description = "Get one Dataverse service principal by system-user GUID or application client ID." -)] -public class ServicePrincipalGetCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalGetCliCommand)); - - [CliOption(Name = "--service-principal", Description = "System-user GUID or application client ID GUID.", Required = true)] - public string ServicePrincipal { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteGetAsync(); - - private async Task ExecuteGetAsync() - { - try - { - var service = TxcServices.Get(); - var app = await service.GetAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); - if (app is null) - { - Logger.LogError("Service principal '{ServicePrincipal}' not found.", ServicePrincipal); - return ExitValidationError; - } - - OutputFormatter.WriteData(app, ServicePrincipalCommandSupport.WriteServicePrincipalDetails); - return ExitSuccess; - } - catch (Exception ex) when (ServicePrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalListCliCommand.cs deleted file mode 100644 index da4718fb..00000000 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalListCliCommand.cs +++ /dev/null @@ -1,54 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; - -/// -/// Lists Dataverse service principals. -/// Usage: txc environment service-principal list [--enabled|--disabled|--all] -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List Dataverse service principals. Defaults to enabled-only when no state flag is provided." -)] -public class ServicePrincipalListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalListCliCommand)); - - [CliOption(Name = "--enabled", Description = "List only enabled service principals. This is the default when no state flag is provided.", Required = false)] - public bool Enabled { get; set; } - - [CliOption(Name = "--disabled", Description = "List only disabled service principals.", Required = false)] - public bool Disabled { get; set; } - - [CliOption(Name = "--all", Description = "List both enabled and disabled service principals.", Required = false)] - public bool All { get; set; } - - protected override Task ExecuteAsync() - { - if (!ServicePrincipalCommandSupport.TryResolveStateFilter(Enabled, Disabled, All, Logger, out var filter)) - return Task.FromResult(ExitValidationError); - - return ExecuteListAsync(filter); - } - - private async Task ExecuteListAsync(DataverseSecurityPrincipalStateFilter filter) - { - try - { - var service = TxcServices.Get(); - var rows = await service.ListAsync(Profile, filter, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(rows, ServicePrincipalCommandSupport.WriteAppTable); - return ExitSuccess; - } - catch (Exception ex) when (ServicePrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalRoleAddCliCommand.cs b/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalRoleAddCliCommand.cs deleted file mode 100644 index 35049ab1..00000000 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalRoleAddCliCommand.cs +++ /dev/null @@ -1,74 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; - -/// -/// Assigns a security role to a Dataverse service principal. -/// Usage: txc environment service-principal role add --service-principal <client-id-or-guid> --role <name-or-guid> -/// -[CliIdempotent] -[CliCommand( - Name = "add", - Description = "Assign a security role to a Dataverse service principal." -)] -public class ServicePrincipalRoleAddCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalRoleAddCliCommand)); - - [CliOption(Name = "--service-principal", Description = "System-user GUID or application client ID GUID.", Required = true)] - public string ServicePrincipal { get; set; } = null!; - - [CliOption(Name = "--role", Description = "Role name or GUID.", Required = true)] - public string Role { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteAddRoleAsync(); - - private async Task ExecuteAddRoleAsync() - { - try - { - var service = TxcServices.Get(); - - var existingRoles = await service.ListRolesAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); - if (existingRoles.Any(r => EnvironmentPrincipalCommandSupport.IsRoleMatch(r, Role))) - { - ServicePrincipalCommandSupport.WriteMutationResult( - new { status = "unchanged", servicePrincipal = ServicePrincipal, role = Role }, - () => - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"Role '{Role}' is already assigned to service principal '{ServicePrincipal}'."); -#pragma warning restore TXC003 - }); - return ExitSuccess; - } - - await service.AddRoleAsync(Profile, ServicePrincipal, Role, CancellationToken.None).ConfigureAwait(false); - - var payload = new - { - status = "role-added", - servicePrincipal = ServicePrincipal, - role = Role, - }; - - ServicePrincipalCommandSupport.WriteMutationResult(payload, () => - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"Role '{Role}' assigned to service principal '{ServicePrincipal}'."); -#pragma warning restore TXC003 - }); - - return ExitSuccess; - } - catch (Exception ex) when (ServicePrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalRoleListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalRoleListCliCommand.cs deleted file mode 100644 index fed9d118..00000000 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalRoleListCliCommand.cs +++ /dev/null @@ -1,42 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; - -/// -/// Lists security roles assigned to a Dataverse service principal. -/// Usage: txc environment service-principal role list --service-principal <client-id-or-guid> -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List security roles assigned to a Dataverse service principal." -)] -public class ServicePrincipalRoleListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalRoleListCliCommand)); - - [CliOption(Name = "--service-principal", Description = "System-user GUID or application client ID GUID.", Required = true)] - public string ServicePrincipal { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteListRolesAsync(); - - private async Task ExecuteListRolesAsync() - { - try - { - var service = TxcServices.Get(); - var rows = await service.ListRolesAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(rows, ServicePrincipalCommandSupport.WriteRoleTable); - return ExitSuccess; - } - catch (Exception ex) when (ServicePrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalRoleRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalRoleRemoveCliCommand.cs deleted file mode 100644 index a5fa40f4..00000000 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalRoleRemoveCliCommand.cs +++ /dev/null @@ -1,63 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; - -/// -/// Removes a security role from a Dataverse service principal. -/// Usage: txc environment service-principal role remove --service-principal <client-id-or-guid> --role <name-or-guid> --yes -/// -[CliDestructive("Permanently removes the security role assignment from the Dataverse service principal.")] -[CliCommand( - Name = "remove", - Description = "Remove a security role from a Dataverse service principal." -)] -public class ServicePrincipalRoleRemoveCliCommand : ProfiledCliCommand, IDestructiveCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalRoleRemoveCliCommand)); - - [CliOption(Name = "--yes", Description = "Skip interactive confirmation.", Required = false)] - public bool Yes { get; set; } - - [CliOption(Name = "--service-principal", Description = "System-user GUID or application client ID GUID.", Required = true)] - public string ServicePrincipal { get; set; } = null!; - - [CliOption(Name = "--role", Description = "Role name or GUID.", Required = true)] - public string Role { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteRemoveRoleAsync(); - - private async Task ExecuteRemoveRoleAsync() - { - try - { - var service = TxcServices.Get(); - await service.RemoveRoleAsync(Profile, ServicePrincipal, Role, CancellationToken.None).ConfigureAwait(false); - - var payload = new - { - status = "role-removed", - servicePrincipal = ServicePrincipal, - role = Role, - }; - - ServicePrincipalCommandSupport.WriteMutationResult(payload, () => - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"Role '{Role}' removed from service principal '{ServicePrincipal}'."); -#pragma warning restore TXC003 - }); - - return ExitSuccess; - } - catch (Exception ex) when (ServicePrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserCliCommand.cs b/src/TALXIS.CLI.Features.Environment/User/UserCliCommand.cs deleted file mode 100644 index c607c30a..00000000 --- a/src/TALXIS.CLI.Features.Environment/User/UserCliCommand.cs +++ /dev/null @@ -1,52 +0,0 @@ -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Environment.User; - -/// -/// Parent command for environment-user operations. -/// Usage: txc environment user [list|get|update|role|self-elevate] -/// -[CliCommand( - Name = "user", - Description = "Manage Dataverse environment users and their security roles.", - Children = new[] - { - typeof(UserListCliCommand), - typeof(UserGetCliCommand), - typeof(UserAddCliCommand), - typeof(UserUpdateCliCommand), - typeof(UserRoleCliCommand), - typeof(UserSelfElevateCliCommand), - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class UserCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} - -/// -/// Sub-resource for Dataverse security-role assignments on environment users. -/// Usage: txc environment user role [list|add|remove] -/// -[CliCommand( - Name = "role", - Description = "List, add, or remove security roles for an environment user.", - Children = new[] - { - typeof(UserRoleListCliCommand), - typeof(UserRoleAddCliCommand), - typeof(UserRoleRemoveCliCommand), - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class UserRoleCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserCliCommandSupport.cs b/src/TALXIS.CLI.Features.Environment/User/UserCliCommandSupport.cs deleted file mode 100644 index a0edc6d5..00000000 --- a/src/TALXIS.CLI.Features.Environment/User/UserCliCommandSupport.cs +++ /dev/null @@ -1,211 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Core.Model; -using TALXIS.CLI.Core.Platforms.PowerPlatform; - -namespace TALXIS.CLI.Features.Environment.User; - -internal static class UserCliCommandSupport -{ - public static bool TryResolveStateFilter( - bool enabled, - bool disabled, - bool all, - ILogger logger, - out DataverseSecurityPrincipalStateFilter filter) - => EnvironmentPrincipalCommandSupport.TryResolveStateFilter(enabled, disabled, all, logger, out filter); - - public static async Task ResolveUserAsync( - IDataverseUserService service, - string? profileName, - string userIdOrUpn, - ILogger logger, - CancellationToken ct) - { - try - { - var user = await service.GetAsync(profileName, userIdOrUpn, ct).ConfigureAwait(false); - if (user is null) - logger.LogError("Dataverse user '{User}' was not found.", userIdOrUpn); - - return user; - } - catch (DataverseAmbiguousMatchException ex) - { - LogAmbiguousMatch(logger, ex); - return null; - } - } - - public static async Task ResolveRoleAsync( - IDataverseRoleService service, - string? profileName, - string roleNameOrGuid, - ILogger logger, - CancellationToken ct) - { - try - { - var role = await service.GetAsync(profileName, roleNameOrGuid, ct).ConfigureAwait(false); - if (role is null) - logger.LogError("Dataverse role '{Role}' was not found.", roleNameOrGuid); - - return role; - } - catch (DataverseAmbiguousMatchException ex) - { - LogAmbiguousMatch(logger, ex); - return null; - } - } - - public static void LogAmbiguousMatch(ILogger logger, DataverseAmbiguousMatchException ex) - { - logger.LogError("Multiple {EntityDisplayName} records matched '{Identifier}'.", ex.EntityDisplayName, ex.Identifier); - foreach (var candidate in ex.Candidates) - { - if (string.IsNullOrWhiteSpace(candidate.Description)) - { - logger.LogError(" - {Name} ({Id})", candidate.Name, candidate.Id); - } - else - { - logger.LogError(" - {Name} [{Description}] ({Id})", candidate.Name, candidate.Description, candidate.Id); - } - } - } - - public static string FormatUserLabel(DataverseUserRecord user) - => user.UserPrincipalName - ?? user.PrimaryEmailAddress - ?? user.FullName - ?? user.Id.ToString(); - - public static void PrintUsersTable(IReadOnlyList rows) - { - if (rows.Count == 0) - { - OutputWriter.WriteLine("No environment users found."); - return; - } - - int nameWidth = Math.Clamp(rows.Max(r => (r.FullName ?? string.Empty).Length), 4, 28); - int upnWidth = Math.Clamp(rows.Max(r => (r.UserPrincipalName ?? string.Empty).Length), 3, 36); - int emailWidth = Math.Clamp(rows.Max(r => (r.PrimaryEmailAddress ?? string.Empty).Length), 5, 36); - int stateWidth = 8; - int buWidth = Math.Clamp(rows.Max(r => (r.BusinessUnitName ?? string.Empty).Length), 13, 28); - - string header = - $"{"Name".PadRight(nameWidth)} | " + - $"{"UPN".PadRight(upnWidth)} | " + - $"{"Email".PadRight(emailWidth)} | " + - $"{"State".PadRight(stateWidth)} | " + - $"{"Business Unit".PadRight(buWidth)} | User ID"; - OutputWriter.WriteLine(header); - OutputWriter.WriteLine(new string('-', header.Length)); - - foreach (var row in rows) - { - OutputWriter.WriteLine( - $"{EnvironmentPrincipalCommandSupport.Truncate(row.FullName ?? string.Empty, nameWidth).PadRight(nameWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.UserPrincipalName ?? string.Empty, upnWidth).PadRight(upnWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.PrimaryEmailAddress ?? string.Empty, emailWidth).PadRight(emailWidth)} | " + - $"{(row.IsDisabled ? "disabled" : "enabled").PadRight(stateWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? string.Empty, buWidth).PadRight(buWidth)} | {row.Id}"); - } - } - - public static void PrintUserDetail(DataverseUserRecord user) - { - OutputWriter.WriteLine($"User ID: {user.Id}"); - OutputWriter.WriteLine($"Name: {user.FullName ?? "-"}"); - OutputWriter.WriteLine($"UPN: {user.UserPrincipalName ?? "-"}"); - OutputWriter.WriteLine($"Email: {user.PrimaryEmailAddress ?? "-"}"); - OutputWriter.WriteLine($"Entra Object ID: {user.AzureActiveDirectoryObjectId?.ToString() ?? "-"}"); - OutputWriter.WriteLine($"State: {(user.IsDisabled ? "disabled" : "enabled")}"); - OutputWriter.WriteLine($"Business Unit: {user.BusinessUnitName ?? "-"}"); - } - - public static void PrintRolesTable(IReadOnlyList rows) - { - if (rows.Count == 0) - { - OutputWriter.WriteLine("No security roles assigned."); - return; - } - - int nameWidth = Math.Clamp(rows.Max(r => r.Name.Length), 4, 48); - int buWidth = Math.Clamp(rows.Max(r => (r.BusinessUnitName ?? string.Empty).Length), 13, 28); - string header = $"{"Role".PadRight(nameWidth)} | {"Business Unit".PadRight(buWidth)} | Role ID"; - OutputWriter.WriteLine(header); - OutputWriter.WriteLine(new string('-', header.Length)); - - foreach (var row in rows.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)) - { - OutputWriter.WriteLine( - $"{EnvironmentPrincipalCommandSupport.Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? string.Empty, buWidth).PadRight(buWidth)} | {row.Id}"); - } - } - - public static bool TryParseRoleIdentifiers( - string? csv, - ILogger logger, - out IReadOnlyList roles) - => EnvironmentPrincipalCommandSupport.TryParseRoleIdentifiers(csv, logger, out roles); - - public static bool TryHandleValidationException(ILogger logger, Exception ex, out int exitCode) - => EnvironmentPrincipalCommandSupport.TryHandleValidationException(logger, ex, LogAmbiguousMatch, out exitCode); - - public static async Task ResolveEnvironmentIdAsync(string? profileName, CancellationToken ct) - { - var resolver = TxcServices.Get(); - var context = await resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); - return await ResolveEnvironmentIdAsync(context, ct).ConfigureAwait(false); - } - - public static async Task ResolveEnvironmentIdAsync(ResolvedProfileContext context, CancellationToken ct) - { - if (context.Connection.EnvironmentId.HasValue) - return context.Connection.EnvironmentId.Value; - - if (string.IsNullOrWhiteSpace(context.Connection.EnvironmentUrl) - || !Uri.TryCreate(context.Connection.EnvironmentUrl, UriKind.Absolute, out var environmentUrl)) - { - throw new InvalidOperationException( - $"Connection '{context.Connection.Id}' has no EnvironmentUrl or EnvironmentId."); - } - - var service = TxcServices.Get(); - var environment = (await service.ListAsync( - context.Profile?.Id, - credentialId: null, - cloud: null, - ct).ConfigureAwait(false)) - .SingleOrDefault(candidate => UrlEquals(candidate.EnvironmentUrl, environmentUrl)); - - return environment?.EnvironmentId - ?? throw new InvalidOperationException( - $"Could not resolve Power Platform environment for URL '{context.Connection.EnvironmentUrl}'."); - } - - /// - /// Applies the environment admin role to the current authenticated - /// caller via . - /// - public static Task SelfElevateAsync(ResolvedProfileContext context, Guid environmentId, CancellationToken ct) - => TxcServices.Get() - .SelfElevateAsync(context.Connection, context.Credential, environmentId, ct); - - private static bool UrlEquals(Uri left, Uri right) - => NormalizeEnvironmentUrl(left).AbsoluteUri.Equals( - NormalizeEnvironmentUrl(right).AbsoluteUri, - StringComparison.OrdinalIgnoreCase); - - private static Uri NormalizeEnvironmentUrl(Uri uri) - => new(uri.GetLeftPart(UriPartial.Path).TrimEnd('/') + "/"); -} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserGetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/User/UserGetCliCommand.cs deleted file mode 100644 index 873bf8ea..00000000 --- a/src/TALXIS.CLI.Features.Environment/User/UserGetCliCommand.cs +++ /dev/null @@ -1,42 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.User; - -/// -/// Gets a single Dataverse environment user by UPN or GUID. -/// Usage: txc environment user get --user <upn-or-guid> -/// -[CliReadOnly] -[CliCommand( - Name = "get", - Description = "Get a single environment user by user principal name or system user GUID." -)] -#pragma warning disable TXC003 -public class UserGetCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserGetCliCommand)); - - [CliOption(Name = "--user", Description = "User principal name or system user GUID.", Required = true)] - public string User { get; set; } = null!; - - protected override async Task ExecuteAsync() - { - var service = TxcServices.Get(); - var user = await UserCliCommandSupport.ResolveUserAsync( - service, - Profile, - User, - Logger, - CancellationToken.None).ConfigureAwait(false); - if (user is null) - return ExitValidationError; - - OutputFormatter.WriteData(user, UserCliCommandSupport.PrintUserDetail); - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/User/UserListCliCommand.cs deleted file mode 100644 index 4796e724..00000000 --- a/src/TALXIS.CLI.Features.Environment/User/UserListCliCommand.cs +++ /dev/null @@ -1,44 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.User; - -/// -/// Lists Dataverse environment users provisioned from Entra ID. -/// Usage: txc environment user list [--enabled|--disabled|--all] -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List environment users. Defaults to enabled users when no state flag is supplied." -)] -#pragma warning disable TXC003 -public class UserListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserListCliCommand)); - - [CliOption(Name = "--enabled", Description = "Show enabled users only. This is the default when no state flag is supplied.", Required = false)] - public bool Enabled { get; set; } - - [CliOption(Name = "--disabled", Description = "Show disabled users only.", Required = false)] - public bool Disabled { get; set; } - - [CliOption(Name = "--all", Description = "Show both enabled and disabled users.", Required = false)] - public bool All { get; set; } - - protected override async Task ExecuteAsync() - { - if (!UserCliCommandSupport.TryResolveStateFilter(Enabled, Disabled, All, Logger, out var filter)) - return ExitValidationError; - - var service = TxcServices.Get(); - var rows = await service.ListAsync(Profile, filter, CancellationToken.None).ConfigureAwait(false); - - OutputFormatter.WriteList(rows, UserCliCommandSupport.PrintUsersTable); - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserRoleAddCliCommand.cs b/src/TALXIS.CLI.Features.Environment/User/UserRoleAddCliCommand.cs deleted file mode 100644 index c6b942fb..00000000 --- a/src/TALXIS.CLI.Features.Environment/User/UserRoleAddCliCommand.cs +++ /dev/null @@ -1,93 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.User; - -/// -/// Assigns a Dataverse security role to an environment user. -/// Usage: txc environment user role add --user <upn-or-guid> --role <name-or-guid> -/// -[CliIdempotent] -[CliCommand( - Name = "add", - Description = "Assign a security role to an environment user." -)] -#pragma warning disable TXC003 -public class UserRoleAddCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserRoleAddCliCommand)); - - [CliOption(Name = "--user", Description = "User principal name or system user GUID.", Required = true)] - public string User { get; set; } = null!; - - [CliOption(Name = "--role", Description = "Security role name or role GUID.", Required = true)] - public string Role { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteAddRoleAsync(); - - private async Task ExecuteAddRoleAsync() - { - var userService = TxcServices.Get(); - var roleService = TxcServices.Get(); - - var user = await UserCliCommandSupport.ResolveUserAsync( - userService, - Profile, - User, - Logger, - CancellationToken.None).ConfigureAwait(false); - if (user is null) - return ExitValidationError; - - var role = await UserCliCommandSupport.ResolveRoleAsync( - roleService, - Profile, - Role, - Logger, - CancellationToken.None).ConfigureAwait(false); - if (role is null) - return ExitValidationError; - - var existingRoles = await userService.ListRolesAsync(Profile, User, CancellationToken.None).ConfigureAwait(false); - if (existingRoles.Any(r => EnvironmentPrincipalCommandSupport.IsRoleMatch(r, Role))) - { - OutputFormatter.WriteData( - new - { - status = "unchanged", - userId = user.Id, - user = UserCliCommandSupport.FormatUserLabel(user), - roleId = role.Id, - role = role.Name, - }, - _ => OutputWriter.WriteLine($"Role '{role.Name}' is already assigned to user '{UserCliCommandSupport.FormatUserLabel(user)}'.")); - return ExitSuccess; - } - - try - { - await userService.AddRoleAsync(Profile, User, Role, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) when (UserCliCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - - OutputFormatter.WriteData( - new - { - status = "assigned", - userId = user.Id, - user = UserCliCommandSupport.FormatUserLabel(user), - roleId = role.Id, - role = role.Name, - }, - _ => OutputWriter.WriteLine($"Role '{role.Name}' assigned to user '{UserCliCommandSupport.FormatUserLabel(user)}'.")); - - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserRoleListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/User/UserRoleListCliCommand.cs deleted file mode 100644 index 6dcb497b..00000000 --- a/src/TALXIS.CLI.Features.Environment/User/UserRoleListCliCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.User; - -/// -/// Lists security roles assigned to an environment user. -/// Usage: txc environment user role list --user <upn-or-guid> -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List security roles assigned to an environment user." -)] -#pragma warning disable TXC003 -public class UserRoleListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserRoleListCliCommand)); - - [CliOption(Name = "--user", Description = "User principal name or system user GUID.", Required = true)] - public string User { get; set; } = null!; - - protected override async Task ExecuteAsync() - { - var userService = TxcServices.Get(); - var user = await UserCliCommandSupport.ResolveUserAsync( - userService, - Profile, - User, - Logger, - CancellationToken.None).ConfigureAwait(false); - if (user is null) - return ExitValidationError; - - var roles = await userService.ListRolesAsync(Profile, User, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(roles, UserCliCommandSupport.PrintRolesTable); - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserRoleRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Environment/User/UserRoleRemoveCliCommand.cs deleted file mode 100644 index f3664464..00000000 --- a/src/TALXIS.CLI.Features.Environment/User/UserRoleRemoveCliCommand.cs +++ /dev/null @@ -1,97 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; -using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.User; - -/// -/// Removes a Dataverse security role from an environment user. -/// Usage: txc environment user role remove --user <upn-or-guid> --role <name-or-guid> -/// -[CliDestructive("Removing a security role can immediately reduce what the user can do in the environment.")] -[CliCommand( - Name = "remove", - Description = "Remove a security role from an environment user." -)] -#pragma warning disable TXC003 -public class UserRoleRemoveCliCommand : ProfiledCliCommand, IDestructiveCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserRoleRemoveCliCommand)); - - [CliOption(Name = "--yes", Description = "Skip interactive confirmation for this destructive operation.", Required = false)] - public bool Yes { get; set; } - - [CliOption(Name = "--user", Description = "User principal name or system user GUID.", Required = true)] - public string User { get; set; } = null!; - - [CliOption(Name = "--role", Description = "Security role name or role GUID.", Required = true)] - public string Role { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteRemoveRoleAsync(); - - private async Task ExecuteRemoveRoleAsync() - { - var userService = TxcServices.Get(); - var roleService = TxcServices.Get(); - - var user = await UserCliCommandSupport.ResolveUserAsync( - userService, - Profile, - User, - Logger, - CancellationToken.None).ConfigureAwait(false); - if (user is null) - return ExitValidationError; - - var role = await UserCliCommandSupport.ResolveRoleAsync( - roleService, - Profile, - Role, - Logger, - CancellationToken.None).ConfigureAwait(false); - if (role is null) - return ExitValidationError; - - var existingRoles = await userService.ListRolesAsync(Profile, User, CancellationToken.None).ConfigureAwait(false); - if (!existingRoles.Any(r => r.Id == role.Id)) - { - OutputFormatter.WriteData( - new - { - status = "unchanged", - userId = user.Id, - user = UserCliCommandSupport.FormatUserLabel(user), - roleId = role.Id, - role = role.Name, - }, - _ => OutputWriter.WriteLine($"Role '{role.Name}' is not assigned to user '{UserCliCommandSupport.FormatUserLabel(user)}'.")); - return ExitSuccess; - } - - try - { - await userService.RemoveRoleAsync(Profile, User, Role, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) when (UserCliCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - - OutputFormatter.WriteData( - new - { - status = "removed", - userId = user.Id, - user = UserCliCommandSupport.FormatUserLabel(user), - roleId = role.Id, - role = role.Name, - }, - _ => OutputWriter.WriteLine($"Role '{role.Name}' removed from user '{UserCliCommandSupport.FormatUserLabel(user)}'.")); - - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserSelfElevateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/User/UserSelfElevateCliCommand.cs deleted file mode 100644 index 9292740e..00000000 --- a/src/TALXIS.CLI.Features.Environment/User/UserSelfElevateCliCommand.cs +++ /dev/null @@ -1,42 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Environment.User; - -/// -/// Grants the current authenticated caller Dataverse admin access in the selected environment. -/// Usage: txc environment user self-elevate -/// -[CliIdempotent] -[CliCommand( - Name = "self-elevate", - Description = "Grant the current authenticated caller the environment admin role in the selected environment. To add other users to this environment, use 'environment user add' instead." -)] -#pragma warning disable TXC003 -public class UserSelfElevateCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserSelfElevateCliCommand)); - - protected override async Task ExecuteAsync() - { - var resolver = TxcServices.Get(); - var context = await resolver.ResolveAsync(Profile, CancellationToken.None).ConfigureAwait(false); - var environmentId = await UserCliCommandSupport.ResolveEnvironmentIdAsync(context, CancellationToken.None).ConfigureAwait(false); - - await UserCliCommandSupport.SelfElevateAsync(context, environmentId, CancellationToken.None).ConfigureAwait(false); - - OutputFormatter.WriteData( - new - { - status = "elevated", - environmentId, - caller = "current", - }, - _ => OutputWriter.WriteLine("Environment admin role applied to the current authenticated caller.")); - - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupCliCommand.cs new file mode 100644 index 00000000..f3ad83ae --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupCliCommand.cs @@ -0,0 +1,82 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Parent command for tenant-level environment groups — Microsoft's own +/// "folder for your environments" concept, used to organize managed +/// environments and serve as the attachment point for governance rules +/// (txc governance policy-rule) and role assignments. +/// Usage: txc governance environment-group [list|get|create|update|delete|environment] +/// +[CliCommand( + Name = "environment-group", + Description = "Manage tenant-level environment groups: folders that organize managed environments and serve as the attachment point for governance rules (txc governance policy-rule) and role assignments. Typical sequence: create a group, add member environments (environment add), grant access via role assignments (role add), then create and assign policy rules to the group (txc governance policy-rule assign --environment-group).", + Children = new[] + { + typeof(EnvironmentGroupListCliCommand), + typeof(EnvironmentGroupGetCliCommand), + typeof(EnvironmentGroupCreateCliCommand), + typeof(EnvironmentGroupUpdateCliCommand), + typeof(EnvironmentGroupDeleteCliCommand), + typeof(EnvironmentGroupEnvironmentCliCommand), + typeof(EnvironmentGroupRoleCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class EnvironmentGroupCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} + +/// +/// Sub-resource for RBAC role assignments held directly on an environment +/// group. These grants apply to every environment currently in the group +/// and every environment added to it later - the same built-in roles +/// (Owner, Contributor, Reader, RBAC Administrator) used by +/// txc security ... role. +/// Usage: txc governance environment-group role [list|add|remove] +/// +[CliCommand( + Name = "role", + Description = "List, grant, or revoke RBAC role assignments on an environment group. Assignments apply to every environment currently in the group and every environment added to it later.", + Children = new[] + { + typeof(EnvironmentGroupRoleListCliCommand), + typeof(EnvironmentGroupRoleAddCliCommand), + typeof(EnvironmentGroupRoleRemoveCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class EnvironmentGroupRoleCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} + +/// +/// Sub-resource for environment-group membership. +/// Usage: txc governance environment-group environment [add|remove] +/// +[CliCommand( + Name = "environment", + Description = "Add or remove member environments from an environment group. Only managed environments can belong to a group; each environment can belong to at most one group at a time.", + Children = new[] + { + typeof(EnvironmentGroupEnvironmentAddCliCommand), + typeof(EnvironmentGroupEnvironmentRemoveCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class EnvironmentGroupEnvironmentCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupCommandSupport.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupCommandSupport.cs new file mode 100644 index 00000000..e6bb449d --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupCommandSupport.cs @@ -0,0 +1,58 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Shared profile-resolution and lookup helpers for +/// txc governance environment-group commands. Environment groups are +/// a tenant-wide resource (like txc security's resources), so this +/// resolves a (Connection, Credential) context the same way +/// SecurityPrincipalCommandSupport does — no active-environment +/// resolution is involved here (that concept only applies to the +/// --environment scope flag on txc security RBAC commands). +/// +internal static class EnvironmentGroupCommandSupport +{ + internal static Task ResolveContextAsync(string? profile, CancellationToken ct) + => TxcServices.Get().ResolveAsync(profile, ct); + + /// + /// Resolves an environment group by GUID id, or by exact/unique + /// case-insensitive display-name match when a non-GUID value is passed. + /// Throws with a clear message + /// when the value matches zero or more than one group by name. + /// + internal static async Task ResolveAsync( + Connection connection, Credential credential, string environmentGroup, CancellationToken ct) + { + var client = TxcServices.Get(); + + if (Guid.TryParse(environmentGroup, out var id)) + { + var byId = await client.GetAsync(connection, credential, id, ct).ConfigureAwait(false); + if (byId is null) + throw new ArgumentException($"No environment group was found with id '{environmentGroup}'."); + + return byId; + } + + var all = await client.ListAsync(connection, credential, ct).ConfigureAwait(false); + var matches = all.Where(g => string.Equals(g.DisplayName, environmentGroup, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (matches.Count == 0) + throw new ArgumentException($"No environment group was found with display name '{environmentGroup}'."); + + if (matches.Count > 1) + { + throw new ArgumentException( + $"Multiple environment groups match display name '{environmentGroup}': " + + string.Join(", ", matches.Select(m => m.Id)) + + ". Specify the environment group id instead."); + } + + return matches[0]; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupCreateCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupCreateCliCommand.cs new file mode 100644 index 00000000..29db1ec4 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupCreateCliCommand.cs @@ -0,0 +1,43 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Creates a new environment group. +/// Usage: txc governance environment-group create --display-name <name> [--description <text>] +/// +[CliIdempotent] +[CliCommand( + Name = "create", + Description = "Create a new environment group. This is step 1 of the environment-group governance sequence: create the group, add member environments (txc governance environment-group environment add), then create and assign policy rules to it (txc governance policy-rule create / assign --environment-group)." +)] +public class EnvironmentGroupCreateCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupCreateCliCommand)); + + [CliOption(Name = "--display-name", Description = "Display name for the new environment group.", Required = true)] + public string DisplayName { get; set; } = string.Empty; + + [CliOption(Name = "--description", Description = "Optional description.", Required = false)] + public string? Description { get; set; } + + protected override async Task ExecuteAsync() + { + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var client = TxcServices.Get(); + + var group = await client.CreateAsync( + context.Connection, + context.Credential, + new PowerPlatformEnvironmentGroupCreateOptions(DisplayName, Description), + CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteResult("created", id: group.Id.ToString()); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupDeleteCliCommand.cs new file mode 100644 index 00000000..1f8e98ee --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupDeleteCliCommand.cs @@ -0,0 +1,75 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Deletes an environment group. +/// Usage: txc governance environment-group delete <environment-group> +/// +/// +/// The service rejects deletion (409 Conflict) while the group still +/// has member environments or assigned policy rules. A future +/// --force option will auto-unassign policies and remove members +/// before retrying delete (tracked separately); for now this command +/// surfaces that conflict with a clear, actionable message instead of a +/// raw HTTP error. +/// +[CliDestructive("Permanently deletes the environment group. Fails if the group still has member environments or assigned policy rules; this action does not delete the member environments themselves.")] +[CliCommand( + Name = "delete", + Description = "Delete an environment group. Fails with a clear error if the group still has member environments or assigned policy rules — remove those first (txc governance environment-group environment remove / txc governance policy-rule unassign)." +)] +public class EnvironmentGroupDeleteCliCommand : ProfiledCliCommand, IDestructiveCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupDeleteCliCommand)); + + [CliOption(Name = "--yes", Description = "Skip interactive confirmation for this destructive operation.", Required = false)] + public bool Yes { get; set; } + + [CliArgument(Description = "Environment group id (GUID) or display name.")] + public string EnvironmentGroup { get; set; } = string.Empty; + + protected override Task ExecuteAsync() => ExecuteDeleteAsync(); + + private async Task ExecuteDeleteAsync() + { + try + { + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var existing = await EnvironmentGroupCommandSupport + .ResolveAsync(context.Connection, context.Credential, EnvironmentGroup, CancellationToken.None) + .ConfigureAwait(false); + + var client = TxcServices.Get(); + await client.DeleteAsync(context.Connection, context.Credential, existing.Id, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteResult("deleted", id: existing.Id.ToString()); + return ExitSuccess; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("EnvironmentsInEnvironmentGroup", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogError( + "Cannot delete environment group '{EnvironmentGroup}': it still has member environments. Remove them first with 'txc governance environment-group environment remove'.", + EnvironmentGroup); + return ExitValidationError; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("PolicyAssignedToEnvironmentGroup", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogError( + "Cannot delete environment group '{EnvironmentGroup}': it still has assigned policy rules. Unassign them first with 'txc governance policy-rule unassign'.", + EnvironmentGroup); + return ExitValidationError; + } + catch (InvalidOperationException ex) + { + Logger.LogError("{Error}", ex.Message); + return ExitValidationError; + } + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupEnvironmentAddCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupEnvironmentAddCliCommand.cs new file mode 100644 index 00000000..cd52c44d --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupEnvironmentAddCliCommand.cs @@ -0,0 +1,43 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Adds a managed environment to an environment group. +/// Usage: txc governance environment-group environment add <environment-group> --environment <id> +/// +[CliIdempotent] +[CliCommand( + Name = "add", + Description = "Add a managed environment to an environment group. The environment must not already belong to another group (each environment can belong to at most one group). The environment immediately inherits every rule published on this group." +)] +public class EnvironmentGroupEnvironmentAddCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupEnvironmentAddCliCommand)); + + [CliArgument(Description = "Environment group id (GUID) or display name.")] + public string EnvironmentGroup { get; set; } = string.Empty; + + [CliOption(Name = "--environment", Description = "Id (GUID) of the environment to add.", Required = true)] + public Guid Environment { get; set; } + + protected override async Task ExecuteAsync() + { + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var group = await EnvironmentGroupCommandSupport + .ResolveAsync(context.Connection, context.Credential, EnvironmentGroup, CancellationToken.None) + .ConfigureAwait(false); + + var client = TxcServices.Get(); + await client.AddEnvironmentAsync(context.Connection, context.Credential, group.Id, Environment, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteResult("added", id: Environment.ToString()); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupEnvironmentRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupEnvironmentRemoveCliCommand.cs new file mode 100644 index 00000000..91f9b748 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupEnvironmentRemoveCliCommand.cs @@ -0,0 +1,43 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Removes a managed environment from an environment group. +/// Usage: txc governance environment-group environment remove <environment-group> --environment <id> +/// +[CliIdempotent] +[CliCommand( + Name = "remove", + Description = "Remove a managed environment from an environment group. The environment retains the last-applied configuration from the group's rules but becomes unlocked, allowing a local admin to modify it going forward." +)] +public class EnvironmentGroupEnvironmentRemoveCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupEnvironmentRemoveCliCommand)); + + [CliArgument(Description = "Environment group id (GUID) or display name.")] + public string EnvironmentGroup { get; set; } = string.Empty; + + [CliOption(Name = "--environment", Description = "Id (GUID) of the environment to remove.", Required = true)] + public Guid Environment { get; set; } + + protected override async Task ExecuteAsync() + { + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var group = await EnvironmentGroupCommandSupport + .ResolveAsync(context.Connection, context.Credential, EnvironmentGroup, CancellationToken.None) + .ConfigureAwait(false); + + var client = TxcServices.Get(); + await client.RemoveEnvironmentAsync(context.Connection, context.Credential, group.Id, Environment, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteResult("removed", id: Environment.ToString()); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupGetCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupGetCliCommand.cs new file mode 100644 index 00000000..7817aded --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupGetCliCommand.cs @@ -0,0 +1,34 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Gets a single environment group by id or display name. +/// Usage: txc governance environment-group get <environment-group> +/// +[CliReadOnly] +[CliCommand( + Name = "get", + Description = "Get a single environment group by id or display name." +)] +public class EnvironmentGroupGetCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupGetCliCommand)); + + [CliArgument(Description = "Environment group id (GUID) or display name.")] + public string EnvironmentGroup { get; set; } = string.Empty; + + protected override async Task ExecuteAsync() + { + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var group = await EnvironmentGroupCommandSupport + .ResolveAsync(context.Connection, context.Credential, EnvironmentGroup, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteData(group, EnvironmentGroupOutput.PrintDetail); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupListCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupListCliCommand.cs new file mode 100644 index 00000000..40022392 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupListCliCommand.cs @@ -0,0 +1,32 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Lists all tenant environment groups. +/// Usage: txc governance environment-group list +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List all environment groups in the tenant, with their id, display name, and member-environment count." +)] +public class EnvironmentGroupListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupListCliCommand)); + + protected override async Task ExecuteAsync() + { + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var client = TxcServices.Get(); + var groups = await client.ListAsync(context.Connection, context.Credential, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(groups, EnvironmentGroupOutput.PrintList); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupOutput.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupOutput.cs new file mode 100644 index 00000000..76750c40 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupOutput.cs @@ -0,0 +1,42 @@ +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +internal static class EnvironmentGroupOutput +{ +#pragma warning disable TXC003 + public static void PrintList(IReadOnlyList groups) + { + if (groups.Count == 0) + { + OutputWriter.WriteLine("No environment groups found."); + return; + } + + int nameWidth = Math.Clamp(groups.Max(g => g.DisplayName.Length), 12, 36); + string header = $"{"Display Name".PadRight(nameWidth)} | {"Environment Group ID".PadRight(36)} | Environments"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var group in groups) + { + OutputWriter.WriteLine( + $"{Truncate(group.DisplayName, nameWidth).PadRight(nameWidth)} | {group.Id} | {group.EnvironmentIds.Count}"); + } + } + + public static void PrintDetail(PowerPlatformEnvironmentGroup group) + { + OutputWriter.WriteLine($"Display Name: {group.DisplayName}"); + OutputWriter.WriteLine($"Group ID: {group.Id}"); + OutputWriter.WriteLine($"Description: {group.Description ?? "-"}"); + OutputWriter.WriteLine($"Created On: {(group.CreatedOn is { } c ? c.ToString("u") : "-")}"); + OutputWriter.WriteLine($"Last Modified: {(group.LastModifiedOn is { } m ? m.ToString("u") : "-")}"); + OutputWriter.WriteLine($"Environments: {(group.EnvironmentIds.Count == 0 ? "-" : string.Join(", ", group.EnvironmentIds))}"); + } + + private static string Truncate(string value, int maxWidth) + => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleAddCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleAddCliCommand.cs new file mode 100644 index 00000000..5b62a3f6 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleAddCliCommand.cs @@ -0,0 +1,73 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Grants a user, Entra group, or service principal an RBAC role on an +/// environment group. The role applies to every environment currently in +/// the group and every environment added to it later. +/// Usage: txc governance environment-group role add <environment-group> --principal-type <type> --principal <value> --role <name-or-guid> +/// +[CliIdempotent] +[CliCommand( + Name = "add", + Description = "Grant a user, Entra group, or service principal an RBAC role (e.g. Owner, Contributor, Reader) on an environment group. The role applies to every environment currently in the group and any added later." +)] +public class EnvironmentGroupRoleAddCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupRoleAddCliCommand)); + + [CliArgument(Description = "Environment group id (GUID) or display name.")] + public string EnvironmentGroup { get; set; } = string.Empty; + + [CliOption(Name = "--principal-type", Description = "Type of principal being granted the role.", Required = true)] + public PowerPlatformPrincipalType PrincipalType { get; set; } + + [CliOption( + Name = "--principal", + Description = "The principal to grant the role to. User/service-principal: object id, user principal name, app id, or display name. Group: Entra object id (GUID) only.", + Required = true)] + public string Principal { get; set; } = null!; + + [CliOption(Name = "--role", Description = "Role name or role id (e.g. Owner, Contributor, Reader).", Required = true)] + public string Role { get; set; } = null!; + + protected override Task ExecuteAsync() => ExecuteAddAsync(); + + private async Task ExecuteAddAsync() + { + try + { + var assignment = await EnvironmentGroupRoleCommandSupport + .AddRoleAsync(Profile, EnvironmentGroup, PrincipalType, Principal, Role, CancellationToken.None) + .ConfigureAwait(false); + + var payload = new + { + status = "role-added", + environmentGroup = EnvironmentGroup, + roleAssignmentId = assignment.RoleAssignmentId, + principalType = assignment.PrincipalType.ToString(), + principalObjectId = assignment.PrincipalObjectId, + roleDefinitionId = assignment.RoleDefinitionId, + }; + + EnvironmentGroupRoleOutput.WriteMutationResult(payload, () => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Role '{Role}' granted to {PrincipalType} '{Principal}' on environment group '{EnvironmentGroup}'."); +#pragma warning restore TXC003 + }); + + return ExitSuccess; + } + catch (Exception ex) when (EnvironmentGroupRoleOutput.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleCommandSupport.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleCommandSupport.cs new file mode 100644 index 00000000..6ade6fbd --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleCommandSupport.cs @@ -0,0 +1,128 @@ +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Platform.PowerPlatform.Control; +using TALXIS.CLI.Platform.PowerPlatform.Control.PowerPlatformRbac; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Command support for txc governance environment-group role .... +/// Reuses 's Microsoft Graph-backed +/// principal resolution (the same logic txc security uses for +/// user/service-principal/group lookups) so this command surface never +/// duplicates Graph query/paging code. Role definitions are resolved +/// against the tenant's full RBAC role catalog because environment-group +/// role assignments use the built-in RBAC roles (Owner/Contributor/Reader/ +/// RBAC Administrator) — the same catalog tenant-scoped assignments use — +/// rather than a separate environment-group-only role set. +/// +internal static class EnvironmentGroupRoleCommandSupport +{ + public static async Task> ListRolesAsync( + string? profile, + string environmentGroup, + CancellationToken ct) + { + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var group = await EnvironmentGroupCommandSupport + .ResolveAsync(context.Connection, context.Credential, environmentGroup, ct) + .ConfigureAwait(false); + + var client = TxcServices.Get(); + return await client.ListAsync(context.Connection, context.Credential, group.Id, ct).ConfigureAwait(false); + } + + public static async Task AddRoleAsync( + string? profile, + string environmentGroup, + PowerPlatformPrincipalType principalType, + string principal, + string roleNameOrId, + CancellationToken ct) + { + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var group = await EnvironmentGroupCommandSupport + .ResolveAsync(context.Connection, context.Credential, environmentGroup, ct) + .ConfigureAwait(false); + + var principalRef = await ResolvePrincipalAsync(context, principalType, principal, ct).ConfigureAwait(false); + var role = await ResolveRoleAsync(context, roleNameOrId, ct).ConfigureAwait(false); + + var client = TxcServices.Get(); + + // Idempotent: if the principal already holds this role on this group, do nothing. + var existing = await client.ListAsync(context.Connection, context.Credential, group.Id, ct).ConfigureAwait(false); + var already = existing.FirstOrDefault(a => + a.PrincipalType == principalRef.PrincipalType + && a.PrincipalObjectId == principalRef.ObjectId + && a.RoleDefinitionId == role.RoleDefinitionId); + if (already is not null) + return already; + + return await client.AddAsync( + context.Connection, context.Credential, group.Id, + principalRef.PrincipalType, principalRef.ObjectId, role.RoleDefinitionId, ct).ConfigureAwait(false); + } + + public static async Task RemoveRoleAsync( + string? profile, + string environmentGroup, + PowerPlatformPrincipalType principalType, + string principal, + string roleNameOrId, + CancellationToken ct) + { + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var group = await EnvironmentGroupCommandSupport + .ResolveAsync(context.Connection, context.Credential, environmentGroup, ct) + .ConfigureAwait(false); + + var principalRef = await ResolvePrincipalAsync(context, principalType, principal, ct).ConfigureAwait(false); + var role = await ResolveRoleAsync(context, roleNameOrId, ct).ConfigureAwait(false); + + var client = TxcServices.Get(); + var existing = await client.ListAsync(context.Connection, context.Credential, group.Id, ct).ConfigureAwait(false); + var matches = existing.Where(a => + a.PrincipalType == principalRef.PrincipalType + && a.PrincipalObjectId == principalRef.ObjectId + && a.RoleDefinitionId == role.RoleDefinitionId) + .ToList(); + + foreach (var match in matches) + { + await client.RemoveAsync(context.Connection, context.Credential, group.Id, match.RoleAssignmentId, ct) + .ConfigureAwait(false); + } + } + + private static Task ResolvePrincipalAsync( + Core.Model.ResolvedProfileContext context, + PowerPlatformPrincipalType principalType, + string principal, + CancellationToken ct) + => TxcServices.Get() + .ResolvePrincipalAsync(context.Connection, context.Credential, principalType, principal, ct); + + private static async Task ResolveRoleAsync( + Core.Model.ResolvedProfileContext context, + string roleNameOrId, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(roleNameOrId); + + var rbac = TxcServices.Get(); + var roles = await rbac.ListRoleDefinitionsAsync(context.Connection, context.Credential, ct).ConfigureAwait(false); + + var matches = Guid.TryParse(roleNameOrId, out var roleId) + ? roles.Where(r => r.RoleDefinitionId == roleId).ToList() + : roles.Where(r => r.RoleDefinitionName.Equals(roleNameOrId.Trim(), StringComparison.OrdinalIgnoreCase)).ToList(); + + if (matches.Count == 0) + throw new TenantRoleNotFoundException(roleNameOrId); + + if (matches.Count > 1) + throw new TenantRoleAmbiguousException(roleNameOrId, matches.Select(r => r.RoleDefinitionName)); + + return matches[0]; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleListCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleListCliCommand.cs new file mode 100644 index 00000000..efcaa5b8 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleListCliCommand.cs @@ -0,0 +1,43 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Lists RBAC role assignments (Owner/Contributor/Reader/RBAC Administrator, +/// or any other tenant role) held directly on an environment group. +/// Usage: txc governance environment-group role list <environment-group> +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List role assignments (users, groups, and service principals) granted directly on an environment group. These assignments apply to every environment in the group." +)] +public class EnvironmentGroupRoleListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupRoleListCliCommand)); + + [CliArgument(Description = "Environment group id (GUID) or display name.")] + public string EnvironmentGroup { get; set; } = string.Empty; + + protected override Task ExecuteAsync() => ExecuteListAsync(); + + private async Task ExecuteListAsync() + { + try + { + var assignments = await EnvironmentGroupRoleCommandSupport + .ListRolesAsync(Profile, EnvironmentGroup, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteList(assignments, EnvironmentGroupRoleOutput.PrintList); + return ExitSuccess; + } + catch (Exception ex) when (EnvironmentGroupRoleOutput.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleOutput.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleOutput.cs new file mode 100644 index 00000000..ad1e6885 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleOutput.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Platform.PowerPlatform.Control; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +internal static class EnvironmentGroupRoleOutput +{ +#pragma warning disable TXC003 + public static void PrintList(IReadOnlyList assignments) + { + if (assignments.Count == 0) + { + OutputWriter.WriteLine("No role assignments found on this environment group."); + return; + } + + string header = $"{"Principal Type".PadRight(14)} | {"Principal Object ID".PadRight(36)} | {"Role Definition ID".PadRight(36)} | Assignment ID"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var assignment in assignments) + { + OutputWriter.WriteLine( + $"{assignment.PrincipalType.ToString().PadRight(14)} | " + + $"{assignment.PrincipalObjectId.ToString().PadRight(36)} | " + + $"{assignment.RoleDefinitionId.ToString().PadRight(36)} | " + + assignment.RoleAssignmentId); + } + } +#pragma warning restore TXC003 + + public static void WriteMutationResult(T payload, Action textRenderer) + => OutputFormatter.WriteData(payload, _ => textRenderer()); + + public static bool TryHandleValidationException(ILogger logger, Exception ex, out int exitCode) + { + if (ex is TenantPrincipalAmbiguousException ambiguousPrincipal) + { + logger.LogError("{Error}", ambiguousPrincipal.Message); + foreach (var candidate in ambiguousPrincipal.Candidates) + logger.LogError("Candidate: {Candidate}", candidate); + + exitCode = 2; + return true; + } + + if (ex is TenantRoleAmbiguousException ambiguousRole) + { + logger.LogError("{Error}", ambiguousRole.Message); + foreach (var candidate in ambiguousRole.CandidateNames) + logger.LogError("Candidate: {Candidate}", candidate); + + exitCode = 2; + return true; + } + + if (ex is ArgumentException or InvalidOperationException) + { + logger.LogError("{Error}", ex.Message); + exitCode = 2; + return true; + } + + exitCode = 0; + return false; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleRemoveCliCommand.cs new file mode 100644 index 00000000..0f393ea8 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupRoleRemoveCliCommand.cs @@ -0,0 +1,74 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Revokes an RBAC role assignment from an environment group. +/// Usage: txc governance environment-group role remove <environment-group> --principal-type <type> --principal <value> --role <name-or-guid> --yes +/// +[CliDestructive("Permanently revokes the role assignment from the environment group.")] +[CliCommand( + Name = "remove", + Description = "Remove (revoke) a role assignment from an environment group." +)] +public class EnvironmentGroupRoleRemoveCliCommand : ProfiledCliCommand, IDestructiveCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupRoleRemoveCliCommand)); + + [CliOption(Name = "--yes", Description = "Skip interactive confirmation.", Required = false)] + public bool Yes { get; set; } + + [CliArgument(Description = "Environment group id (GUID) or display name.")] + public string EnvironmentGroup { get; set; } = string.Empty; + + [CliOption(Name = "--principal-type", Description = "Type of principal the role is being revoked from.", Required = true)] + public PowerPlatformPrincipalType PrincipalType { get; set; } + + [CliOption( + Name = "--principal", + Description = "The principal to revoke the role from. User/service-principal: object id, user principal name, app id, or display name. Group: Entra object id (GUID) only.", + Required = true)] + public string Principal { get; set; } = null!; + + [CliOption(Name = "--role", Description = "Role name or role id (e.g. Owner, Contributor, Reader).", Required = true)] + public string Role { get; set; } = null!; + + protected override Task ExecuteAsync() => ExecuteRemoveAsync(); + + private async Task ExecuteRemoveAsync() + { + try + { + await EnvironmentGroupRoleCommandSupport + .RemoveRoleAsync(Profile, EnvironmentGroup, PrincipalType, Principal, Role, CancellationToken.None) + .ConfigureAwait(false); + + var payload = new + { + status = "role-removed", + environmentGroup = EnvironmentGroup, + principalType = PrincipalType.ToString(), + principal = Principal, + role = Role, + }; + + EnvironmentGroupRoleOutput.WriteMutationResult(payload, () => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Role '{Role}' revoked from {PrincipalType} '{Principal}' on environment group '{EnvironmentGroup}'."); +#pragma warning restore TXC003 + }); + + return ExitSuccess; + } + catch (Exception ex) when (EnvironmentGroupRoleOutput.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } +} diff --git a/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupUpdateCliCommand.cs b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupUpdateCliCommand.cs new file mode 100644 index 00000000..c2b52800 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/EnvironmentGroup/EnvironmentGroupUpdateCliCommand.cs @@ -0,0 +1,56 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.EnvironmentGroup; + +/// +/// Updates an existing environment group's display name and/or description. +/// Usage: txc governance environment-group update <environment-group> [--display-name <name>] [--description <text>] +/// +[CliIdempotent] +[CliCommand( + Name = "update", + Description = "Update an environment group's display name and/or description. Only the fields you pass are changed." +)] +public class EnvironmentGroupUpdateCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentGroupUpdateCliCommand)); + + [CliArgument(Description = "Environment group id (GUID) or display name.")] + public string EnvironmentGroup { get; set; } = string.Empty; + + [CliOption(Name = "--display-name", Description = "New display name.", Required = false)] + public string? DisplayName { get; set; } + + [CliOption(Name = "--description", Description = "New description.", Required = false)] + public string? Description { get; set; } + + protected override async Task ExecuteAsync() + { + if (DisplayName is null && Description is null) + { + Logger.LogError("Nothing to update: pass --display-name and/or --description."); + return ExitValidationError; + } + + var context = await EnvironmentGroupCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var existing = await EnvironmentGroupCommandSupport + .ResolveAsync(context.Connection, context.Credential, EnvironmentGroup, CancellationToken.None) + .ConfigureAwait(false); + + var client = TxcServices.Get(); + await client.UpdateAsync( + context.Connection, + context.Credential, + existing.Id, + new PowerPlatformEnvironmentGroupUpdateOptions(DisplayName, Description), + CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteResult("updated", id: existing.Id.ToString()); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/GovernanceCliCommand.cs b/src/TALXIS.CLI.Features.Governance/GovernanceCliCommand.cs new file mode 100644 index 00000000..1c173a86 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/GovernanceCliCommand.cs @@ -0,0 +1,30 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Governance; + +/// +/// Top-level command for tenant-wide governance rules and configuration: +/// environment groups (folders that organize managed environments) and +/// rule-based policies (governance rules applied across groups/ +/// environments, e.g. "Advanced connector policy"). See +/// txc security for identity/RBAC — that is a distinct concern from +/// governance, even though both are tenant-wide. +/// Usage: txc governance [environment-group|policy-rule] +/// +[CliCommand( + Name = "governance", + Description = "Discover and manage tenant-wide governance rules and configuration: environment groups and rule-based policies. See 'txc security' for tenant identity and role assignments.", + Children = new[] + { + typeof(EnvironmentGroup.EnvironmentGroupCliCommand), + typeof(PolicyRule.PolicyRuleCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class GovernanceCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleAssignCliCommand.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleAssignCliCommand.cs new file mode 100644 index 00000000..f918ff2b --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleAssignCliCommand.cs @@ -0,0 +1,83 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +/// +/// Assigns a policy to an environment group or a single environment. +/// Exactly one of --environment-group/--environment must be +/// supplied. Unassigning is not supported by the underlying API (see +/// IPowerPlatformPolicyRuleClient remarks) — to stop enforcing a +/// policy on one member of a group, exclude it with --exclude-environment +/// on a group-wide assignment instead. +/// Usage: txc governance policy-rule assign <policy> (--environment-group <id> | --environment <id>) [--exclude-environment <id> ...] +/// +[CliIdempotent] +[CliCommand( + Name = "assign", + Description = "Assign a policy to an environment group or a single environment. Exactly one of --environment-group/--environment is required. When assigning to a group, use --exclude-environment (repeatable) to exempt specific member environments. Unassigning a policy is not supported by the Power Platform governance API as of this writing; to stop enforcing a group-wide policy on one environment, reassign with that environment added to --exclude-environment." +)] +public class PolicyRuleAssignCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PolicyRuleAssignCliCommand)); + + [CliArgument(Description = "Policy id (GUID) or name.")] + public string Policy { get; set; } = string.Empty; + + [CliOption(Name = "--environment-group", Description = "Id (GUID) of the environment group to assign the policy to. Mutually exclusive with --environment.", Required = false)] + public Guid? EnvironmentGroup { get; set; } + + [CliOption(Name = "--environment", Description = "Id (GUID) of the single environment to assign the policy to. Mutually exclusive with --environment-group.", Required = false)] + public Guid? Environment { get; set; } + + [CliOption(Name = "--exclude-environment", Description = "Id (GUID) of a member environment to exempt from a group-wide assignment. Repeatable. Only valid with --environment-group.", Required = false)] + public Guid[]? ExcludeEnvironment { get; set; } + + protected override async Task ExecuteAsync() + { + if (EnvironmentGroup is null == (Environment is null)) + { + Logger.LogError("Specify exactly one of --environment-group or --environment."); + return ExitValidationError; + } + + if (Environment is not null && ExcludeEnvironment is { Length: > 0 }) + { + Logger.LogError("--exclude-environment is only valid with --environment-group."); + return ExitValidationError; + } + + var context = await PolicyRuleCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var policy = await PolicyRuleCommandSupport + .ResolveAsync(context.Connection, context.Credential, Policy, CancellationToken.None) + .ConfigureAwait(false); + + var client = TxcServices.Get(); + + if (EnvironmentGroup is { } groupId) + { + var overrides = ExcludeEnvironment? + .Select(id => new PowerPlatformPolicyAssignmentOverride( + PowerPlatformPolicyBehaviorType.Exclude, id, PowerPlatformPolicyAssignmentResourceType.Environment)) + .ToList(); + + await client.AssignToEnvironmentGroupAsync(context.Connection, context.Credential, policy.Id, groupId, overrides, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteResult("assigned", id: groupId.ToString()); + } + else + { + await client.AssignToEnvironmentAsync(context.Connection, context.Credential, policy.Id, Environment!.Value, null, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteResult("assigned", id: Environment!.Value.ToString()); + } + + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleAssignmentListCliCommand.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleAssignmentListCliCommand.cs new file mode 100644 index 00000000..e099c71c --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleAssignmentListCliCommand.cs @@ -0,0 +1,51 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +/// +/// Lists policy assignments, optionally filtered to exactly one of policy, +/// environment group, or environment. +/// Usage: txc governance policy-rule assignment list [--policy <id> | --environment-group <id> | --environment <id>] +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List policy assignments. With no filter, lists every assignment in the tenant. Pass at most one of --policy/--environment-group/--environment to narrow the list to one dimension." +)] +public class PolicyRuleAssignmentListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PolicyRuleAssignmentListCliCommand)); + + [CliOption(Name = "--policy", Description = "Id (GUID) of a policy to filter by. Mutually exclusive with --environment-group/--environment.", Required = false)] + public Guid? Policy { get; set; } + + [CliOption(Name = "--environment-group", Description = "Id (GUID) of an environment group to filter by. Mutually exclusive with --policy/--environment.", Required = false)] + public Guid? EnvironmentGroup { get; set; } + + [CliOption(Name = "--environment", Description = "Id (GUID) of an environment to filter by. Mutually exclusive with --policy/--environment-group.", Required = false)] + public Guid? Environment { get; set; } + + protected override async Task ExecuteAsync() + { + int filterCount = new[] { Policy, EnvironmentGroup, Environment }.Count(f => f is not null); + if (filterCount > 1) + { + Logger.LogError("Specify at most one of --policy, --environment-group, or --environment."); + return ExitValidationError; + } + + var context = await PolicyRuleCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var client = TxcServices.Get(); + + var assignments = await client.ListAssignmentsAsync( + context.Connection, context.Credential, Policy, EnvironmentGroup, Environment, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(assignments, PolicyRuleOutput.PrintAssignmentList); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleCliCommand.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleCliCommand.cs new file mode 100644 index 00000000..387bf973 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleCliCommand.cs @@ -0,0 +1,55 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +/// +/// Parent command for rule-based policies — the modern governance/policy +/// framework that is replacing classic DLP policies. Targets the confirmed +/// "Advanced Connector Policy" rule type; other rule types can be authored +/// via raw JSON once their shapes are confirmed by Microsoft. +/// Usage: txc governance policy-rule [list|get|create|update|remove-rule|assign|assignment] +/// +[CliCommand( + Name = "policy-rule", + Description = "Manage tenant-wide rule-based policies (the modern governance framework replacing classic DLP). Typical sequence: create a policy (optionally with a ConnectorManagement / Advanced Connector Policy rule set via --allow-connector), then assign it to an environment group or environment. Note: deleting a policy and unassigning it from a resource are not supported by the underlying Power Platform governance API as of this writing — see each command's description for the closest supported alternative.", + Children = new[] + { + typeof(PolicyRuleListCliCommand), + typeof(PolicyRuleGetCliCommand), + typeof(PolicyRuleCreateCliCommand), + typeof(PolicyRuleUpdateCliCommand), + typeof(PolicyRuleRemoveRuleCliCommand), + typeof(PolicyRuleAssignCliCommand), + typeof(PolicyRuleAssignmentCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class PolicyRuleCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} + +/// +/// Sub-resource for read-only visibility into policy assignments across +/// the tenant. +/// Usage: txc governance policy-rule assignment list +/// +[CliCommand( + Name = "assignment", + Description = "List policy assignments across the tenant, optionally filtered by policy, environment group, or environment.", + Children = new[] + { + typeof(PolicyRuleAssignmentListCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class PolicyRuleAssignmentCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleCommandSupport.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleCommandSupport.cs new file mode 100644 index 00000000..de4d82ed --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleCommandSupport.cs @@ -0,0 +1,122 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +/// +/// Shared profile-resolution and lookup helpers for +/// txc governance policy-rule commands. Policies are a tenant-wide +/// resource, resolved the same way EnvironmentGroupCommandSupport +/// resolves environment groups. +/// +internal static class PolicyRuleCommandSupport +{ + internal static Task ResolveContextAsync(string? profile, CancellationToken ct) + => TxcServices.Get().ResolveAsync(profile, ct); + + /// + /// Resolves a policy by GUID id, or by exact/unique case-insensitive + /// name match when a non-GUID value is passed. + /// + internal static async Task ResolveAsync( + Connection connection, Credential credential, string policy, CancellationToken ct) + { + var client = TxcServices.Get(); + + if (Guid.TryParse(policy, out var id)) + { + var byId = await client.GetAsync(connection, credential, id, ct).ConfigureAwait(false); + if (byId is null) + throw new ArgumentException($"No policy was found with id '{policy}'."); + + return byId; + } + + var all = await client.ListAsync(connection, credential, ct).ConfigureAwait(false); + var matches = all.Where(p => string.Equals(p.Name, policy, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (matches.Count == 0) + throw new ArgumentException($"No policy was found with name '{policy}'."); + + if (matches.Count > 1) + { + throw new ArgumentException( + $"Multiple policies match name '{policy}': " + + string.Join(", ", matches.Select(m => m.Id)) + + ". Specify the policy id instead."); + } + + return matches[0]; + } + + /// + /// Builds the one a create/update + /// invocation supplies, from either raw JSON or the friendlier + /// --allow-connector shorthand — never both. Returns + /// null when the caller supplied no rule-set options at all + /// (e.g. a name-only rename during update). + /// + internal static PowerPlatformPolicyRuleSet? BuildRuleSet( + string ruleSetId, string ruleSetVersion, string? inputsJson, IReadOnlyList? allowConnectors) + { + bool hasInputsJson = !string.IsNullOrWhiteSpace(inputsJson); + bool hasAllowConnectors = allowConnectors is { Count: > 0 }; + + if (hasInputsJson && hasAllowConnectors) + { + throw new ArgumentException( + "Specify either --rule-set-inputs-json or --allow-connector, not both."); + } + + if (!hasInputsJson && !hasAllowConnectors) + return null; + + string resolvedInputsJson; + if (hasInputsJson) + { + resolvedInputsJson = inputsJson!; + } + else + { + var rules = allowConnectors!.Select(ParseAllowConnector).ToList(); + resolvedInputsJson = new PowerPlatformAdvancedConnectorPolicyInputs(rules).ToInputsJson(); + } + + return new PowerPlatformPolicyRuleSet(ruleSetId, ruleSetVersion, resolvedInputsJson); + } + + /// + /// Parses one --allow-connector value: either connectorId + /// (every action and connection type on that connector is allowed) or + /// connectorId=action1,action2 (only the listed actions are + /// allowed). + /// + private static PowerPlatformAllowedConnectorRule ParseAllowConnector(string value) + { + var parts = value.Split('=', 2); + var connectorId = parts[0].Trim(); + + if (string.IsNullOrWhiteSpace(connectorId)) + throw new ArgumentException($"Invalid --allow-connector value '{value}': connector id is required."); + + if (parts.Length == 1) + { + return new PowerPlatformAllowedConnectorRule( + connectorId, PowerPlatformAllowedConnectorRule.AllAllowedMode, null, + PowerPlatformAllowedConnectorRule.AllAllowedMode); + } + + var actions = parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + if (actions.Count == 0) + { + throw new ArgumentException( + $"Invalid --allow-connector value '{value}': at least one action is required after '='."); + } + + return new PowerPlatformAllowedConnectorRule( + connectorId, PowerPlatformAllowedConnectorRule.SomeAllowedMode, actions, + PowerPlatformAllowedConnectorRule.AllAllowedMode); + } +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleCreateCliCommand.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleCreateCliCommand.cs new file mode 100644 index 00000000..16129800 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleCreateCliCommand.cs @@ -0,0 +1,60 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +/// +/// Creates a new rule-based policy, optionally with one rule set attached. +/// Usage: txc governance policy-rule create --name <name> +/// [--allow-connector <id>[=action1,action2] ... | --rule-set-inputs-json <json>] +/// [--rule-set-id <id>] [--rule-set-version <version>] +/// +[CliIdempotent] +[CliCommand( + Name = "create", + Description = "Create a new rule-based policy. This is step 1 of the policy governance sequence: create the policy (optionally with its first rule set), then assign it to an environment group or environment (txc governance policy-rule assign). Use --allow-connector for the confirmed Advanced Connector Policy rule type; use --rule-set-inputs-json for any other rule type once its shape is known." +)] +public class PolicyRuleCreateCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PolicyRuleCreateCliCommand)); + + [CliOption(Name = "--name", Description = "Name for the new policy.", Required = true)] + public string Name { get; set; } = string.Empty; + + [CliOption(Name = "--rule-set-id", Description = "Rule set type discriminator. Defaults to \"ConnectorManagement\" (the only confirmed rule type: Advanced Connector Policy).", Required = false)] + public string RuleSetId { get; set; } = PowerPlatformPolicyRuleSet.ConnectorManagementRuleSetId; + + [CliOption(Name = "--rule-set-version", Description = "Rule set version.", Required = false)] + public string RuleSetVersion { get; set; } = "1"; + + [CliOption(Name = "--allow-connector", Description = "Allow-list one connector for the ConnectorManagement (Advanced Connector Policy) rule set. Repeatable. Format: connectorId (allow every action) or connectorId=action1,action2 (allow only the listed actions). Connectors not listed are blocked by default.", Required = false)] + public string[]? AllowConnector { get; set; } + + [CliOption(Name = "--rule-set-inputs-json", Description = "Raw JSON for the rule set's \"inputs\" object. Use this instead of --allow-connector for rule types other than ConnectorManagement.", Required = false)] + public string? RuleSetInputsJson { get; set; } + + protected override async Task ExecuteAsync() + { + var ruleSet = PolicyRuleCommandSupport.BuildRuleSet(RuleSetId, RuleSetVersion, RuleSetInputsJson, AllowConnector); + + var context = await PolicyRuleCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var client = TxcServices.Get(); + + var ruleSets = ruleSet is null + ? Array.Empty() + : new[] { ruleSet }; + + var policy = await client.CreateAsync( + context.Connection, + context.Credential, + new PowerPlatformPolicyCreateOptions(Name, ruleSets), + CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteResult("created", id: policy.Id.ToString()); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleGetCliCommand.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleGetCliCommand.cs new file mode 100644 index 00000000..e0a367e5 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleGetCliCommand.cs @@ -0,0 +1,34 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +/// +/// Gets a single rule-based policy by id or name. +/// Usage: txc governance policy-rule get <policy> +/// +[CliReadOnly] +[CliCommand( + Name = "get", + Description = "Get a single rule-based policy by id or name, including its rule sets." +)] +public class PolicyRuleGetCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PolicyRuleGetCliCommand)); + + [CliArgument(Description = "Policy id (GUID) or name.")] + public string Policy { get; set; } = string.Empty; + + protected override async Task ExecuteAsync() + { + var context = await PolicyRuleCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var policy = await PolicyRuleCommandSupport + .ResolveAsync(context.Connection, context.Credential, Policy, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteData(policy, PolicyRuleOutput.PrintDetail); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleListCliCommand.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleListCliCommand.cs new file mode 100644 index 00000000..b0c93332 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleListCliCommand.cs @@ -0,0 +1,32 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +/// +/// Lists all tenant rule-based policies. +/// Usage: txc governance policy-rule list +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List all rule-based policies in the tenant, with their id, name, and rule-set count." +)] +public class PolicyRuleListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PolicyRuleListCliCommand)); + + protected override async Task ExecuteAsync() + { + var context = await PolicyRuleCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var client = TxcServices.Get(); + var policies = await client.ListAsync(context.Connection, context.Credential, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(policies, PolicyRuleOutput.PrintList); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleOutput.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleOutput.cs new file mode 100644 index 00000000..f2a005f8 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleOutput.cs @@ -0,0 +1,62 @@ +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +internal static class PolicyRuleOutput +{ +#pragma warning disable TXC003 + public static void PrintList(IReadOnlyList policies) + { + if (policies.Count == 0) + { + OutputWriter.WriteLine("No policies found."); + return; + } + + int nameWidth = Math.Clamp(policies.Max(p => p.Name.Length), 8, 36); + string header = $"{"Name".PadRight(nameWidth)} | {"Policy ID".PadRight(36)} | Rule Sets"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var policy in policies) + { + OutputWriter.WriteLine($"{Truncate(policy.Name, nameWidth).PadRight(nameWidth)} | {policy.Id} | {policy.RuleSetCount}"); + } + } + + public static void PrintDetail(PowerPlatformPolicy policy) + { + OutputWriter.WriteLine($"Name: {policy.Name}"); + OutputWriter.WriteLine($"Policy ID: {policy.Id}"); + OutputWriter.WriteLine($"Tenant ID: {policy.TenantId ?? "-"}"); + OutputWriter.WriteLine($"Last Modified: {(policy.LastModified is { } m ? m.ToString("u") : "-")}"); + OutputWriter.WriteLine($"Rule Sets: {(policy.RuleSets.Count == 0 ? "-" : string.Join(", ", policy.RuleSets.Select(r => $"{r.Id} (v{r.Version})")))}"); + } + + public static void PrintAssignmentList(IReadOnlyList assignments) + { + if (assignments.Count == 0) + { + OutputWriter.WriteLine("No policy assignments found."); + return; + } + + string header = $"{"Policy ID".PadRight(36)} | {"Resource Type".PadRight(16)} | {"Resource ID".PadRight(36)} | Rule Sets"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var assignment in assignments) + { + OutputWriter.WriteLine( + $"{assignment.PolicyId.ToString().PadRight(36)} | " + + $"{assignment.ResourceType.ToString().PadRight(16)} | " + + $"{assignment.ResourceId.ToString().PadRight(36)} | " + + assignment.RuleSetCount); + } + } +#pragma warning restore TXC003 + + private static string Truncate(string value, int maxWidth) + => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleRemoveRuleCliCommand.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleRemoveRuleCliCommand.cs new file mode 100644 index 00000000..01172251 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleRemoveRuleCliCommand.cs @@ -0,0 +1,45 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +/// +/// Removes one rule set from a policy. The policy itself is not deleted — +/// deleting a policy is not supported by the underlying API (see +/// IPowerPlatformPolicyRuleClient remarks). +/// Usage: txc governance policy-rule remove-rule <policy> [--rule-set-id <id>] +/// +[CliIdempotent] +[CliCommand( + Name = "remove-rule", + Description = "Remove one rule set from a policy, identified by its rule set id (defaults to \"ConnectorManagement\"). The policy itself is not deleted; deleting a policy entirely is not supported by the Power Platform governance API as of this writing." +)] +public class PolicyRuleRemoveRuleCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PolicyRuleRemoveRuleCliCommand)); + + [CliArgument(Description = "Policy id (GUID) or name.")] + public string Policy { get; set; } = string.Empty; + + [CliOption(Name = "--rule-set-id", Description = "Id of the rule set to remove. Defaults to \"ConnectorManagement\".", Required = false)] + public string RuleSetId { get; set; } = PowerPlatformPolicyRuleSet.ConnectorManagementRuleSetId; + + protected override async Task ExecuteAsync() + { + var context = await PolicyRuleCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var existing = await PolicyRuleCommandSupport + .ResolveAsync(context.Connection, context.Credential, Policy, CancellationToken.None) + .ConfigureAwait(false); + + var client = TxcServices.Get(); + await client.RemoveRuleSetAsync(context.Connection, context.Credential, existing.Id, RuleSetId, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteResult("removed", id: RuleSetId); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleUpdateCliCommand.cs b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleUpdateCliCommand.cs new file mode 100644 index 00000000..fcd02130 --- /dev/null +++ b/src/TALXIS.CLI.Features.Governance/PolicyRule/PolicyRuleUpdateCliCommand.cs @@ -0,0 +1,73 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Governance.PolicyRule; + +/// +/// Renames a policy and/or adds or updates one rule set on it. Additive: +/// existing rule sets not targeted by this call are left untouched. Use +/// remove-rule to remove a rule set entirely. +/// Usage: txc governance policy-rule update <policy> [--name <name>] +/// [--allow-connector <id>[=action1,action2] ... | --rule-set-inputs-json <json>] +/// [--rule-set-id <id>] [--rule-set-version <version>] +/// +[CliIdempotent] +[CliCommand( + Name = "update", + Description = "Rename a policy and/or add or update one rule set on it. Additive: existing rule sets not targeted by this call are left untouched. Use 'remove-rule' to remove a rule set entirely." +)] +public class PolicyRuleUpdateCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PolicyRuleUpdateCliCommand)); + + [CliArgument(Description = "Policy id (GUID) or name.")] + public string Policy { get; set; } = string.Empty; + + [CliOption(Name = "--name", Description = "New name for the policy.", Required = false)] + public string? Name { get; set; } + + [CliOption(Name = "--rule-set-id", Description = "Rule set type discriminator to add or update. Defaults to \"ConnectorManagement\" (the only confirmed rule type: Advanced Connector Policy).", Required = false)] + public string RuleSetId { get; set; } = PowerPlatformPolicyRuleSet.ConnectorManagementRuleSetId; + + [CliOption(Name = "--rule-set-version", Description = "Rule set version.", Required = false)] + public string RuleSetVersion { get; set; } = "1"; + + [CliOption(Name = "--allow-connector", Description = "Allow-list one connector for the ConnectorManagement (Advanced Connector Policy) rule set. Repeatable. Format: connectorId (allow every action) or connectorId=action1,action2 (allow only the listed actions). Connectors not listed are blocked by default.", Required = false)] + public string[]? AllowConnector { get; set; } + + [CliOption(Name = "--rule-set-inputs-json", Description = "Raw JSON for the rule set's \"inputs\" object. Use this instead of --allow-connector for rule types other than ConnectorManagement.", Required = false)] + public string? RuleSetInputsJson { get; set; } + + protected override async Task ExecuteAsync() + { + var ruleSet = PolicyRuleCommandSupport.BuildRuleSet(RuleSetId, RuleSetVersion, RuleSetInputsJson, AllowConnector); + + if (Name is null && ruleSet is null) + { + Logger.LogError("Nothing to update: pass --name and/or a rule set (--allow-connector or --rule-set-inputs-json)."); + return ExitValidationError; + } + + var context = await PolicyRuleCommandSupport.ResolveContextAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var existing = await PolicyRuleCommandSupport + .ResolveAsync(context.Connection, context.Credential, Policy, CancellationToken.None) + .ConfigureAwait(false); + + var client = TxcServices.Get(); + var ruleSets = ruleSet is null ? null : new[] { ruleSet }; + + await client.UpdateAsync( + context.Connection, + context.Credential, + existing.Id, + new PowerPlatformPolicyPatchOptions(Name, ruleSets), + CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteResult("updated", id: existing.Id.ToString()); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Tenant/TALXIS.CLI.Features.Tenant.csproj b/src/TALXIS.CLI.Features.Governance/TALXIS.CLI.Features.Governance.csproj similarity index 100% rename from src/TALXIS.CLI.Features.Tenant/TALXIS.CLI.Features.Tenant.csproj rename to src/TALXIS.CLI.Features.Governance/TALXIS.CLI.Features.Governance.csproj diff --git a/src/TALXIS.CLI.Features.Tenant/Group/GroupCliCommand.cs b/src/TALXIS.CLI.Features.Security/Group/GroupCliCommand.cs similarity index 86% rename from src/TALXIS.CLI.Features.Tenant/Group/GroupCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Group/GroupCliCommand.cs index 74587324..6fe17906 100644 --- a/src/TALXIS.CLI.Features.Tenant/Group/GroupCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Group/GroupCliCommand.cs @@ -1,14 +1,14 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Features.Tenant.Group; +namespace TALXIS.CLI.Features.Security.Group; /// /// Parent command for tenant-wide role assignment operations on an Entra group. -/// Usage: txc tenant group role [list|add|remove] +/// Usage: txc security group role [list|add|remove] /// /// /// There is deliberately no list/get sub-command here (unlike -/// tenant user/tenant service-principal): searching or resolving groups by +/// security user/security service-principal): searching or resolving groups by /// display name requires the Microsoft Graph Group.Read.All /// permission, which is not pre-consented for this CLI's Entra app /// registration in most tenants and we intentionally never prompt for extra @@ -36,7 +36,7 @@ public void Run(CliContext context) /// /// Sub-resource for tenant-wide role assignments on an Entra group. -/// Usage: txc tenant group role [list|add|remove] +/// Usage: txc security group role [list|add|remove] /// [CliCommand( Name = "role", diff --git a/src/TALXIS.CLI.Features.Tenant/Group/GroupCommandSupport.cs b/src/TALXIS.CLI.Features.Security/Group/GroupCommandSupport.cs similarity index 72% rename from src/TALXIS.CLI.Features.Tenant/Group/GroupCommandSupport.cs rename to src/TALXIS.CLI.Features.Security/Group/GroupCommandSupport.cs index 04c6b224..bf0c4a64 100644 --- a/src/TALXIS.CLI.Features.Tenant/Group/GroupCommandSupport.cs +++ b/src/TALXIS.CLI.Features.Security/Group/GroupCommandSupport.cs @@ -1,12 +1,12 @@ using TALXIS.CLI.Core.Contracts.PowerPlatform; using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Features.Tenant; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Platform.PowerPlatform.Control; -namespace TALXIS.CLI.Features.Tenant.Group; +namespace TALXIS.CLI.Features.Security.Group; /// -/// Command support for txc tenant group role .... Unlike the user/app +/// Command support for txc security group role .... Unlike the user/app /// equivalents, this deliberately never calls Microsoft Graph to resolve a /// group by display name — that would require the Group.Read.All /// permission, which is not pre-consented for this CLI's Entra app @@ -21,8 +21,8 @@ public static async Task> ListR string group, CancellationToken ct) { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); return await resolver.ListAssignmentsAsync( context.Connection, context.Credential, @@ -37,8 +37,8 @@ public static async Task AddRoleAsync( string role, CancellationToken ct) { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); await resolver.AddAssignmentAsync( context.Connection, context.Credential, @@ -54,8 +54,8 @@ public static async Task RemoveRoleAsync( string role, CancellationToken ct) { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); await resolver.RemoveAssignmentAsync( context.Connection, context.Credential, diff --git a/src/TALXIS.CLI.Features.Tenant/Group/GroupRoleAddCliCommand.cs b/src/TALXIS.CLI.Features.Security/Group/GroupRoleAddCliCommand.cs similarity index 80% rename from src/TALXIS.CLI.Features.Tenant/Group/GroupRoleAddCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Group/GroupRoleAddCliCommand.cs index ed2b9911..99dc6740 100644 --- a/src/TALXIS.CLI.Features.Tenant/Group/GroupRoleAddCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Group/GroupRoleAddCliCommand.cs @@ -1,14 +1,14 @@ using DotMake.CommandLine; using Microsoft.Extensions.Logging; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Tenant.Group; +namespace TALXIS.CLI.Features.Security.Group; /// /// Assigns a tenant role to an Entra group. -/// Usage: txc tenant group role add --group <object-id> --role <name-or-guid> +/// Usage: txc security group role add --group <object-id> --role <name-or-guid> /// [CliIdempotent] [CliCommand( @@ -40,7 +40,7 @@ private async Task ExecuteAddRoleAsync() role = Role, }; - TenantPrincipalCommandSupport.WriteMutationResult(payload, () => + SecurityPrincipalCommandSupport.WriteMutationResult(payload, () => { #pragma warning disable TXC003 OutputWriter.WriteLine($"Role '{Role}' assigned to group '{Group}'."); @@ -49,7 +49,7 @@ private async Task ExecuteAddRoleAsync() return ExitSuccess; } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) { return exitCode; } diff --git a/src/TALXIS.CLI.Features.Tenant/Group/GroupRoleListCliCommand.cs b/src/TALXIS.CLI.Features.Security/Group/GroupRoleListCliCommand.cs similarity index 74% rename from src/TALXIS.CLI.Features.Tenant/Group/GroupRoleListCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Group/GroupRoleListCliCommand.cs index fa3fd5a9..e981d13a 100644 --- a/src/TALXIS.CLI.Features.Tenant/Group/GroupRoleListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Group/GroupRoleListCliCommand.cs @@ -1,14 +1,14 @@ using DotMake.CommandLine; using Microsoft.Extensions.Logging; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Tenant.Group; +namespace TALXIS.CLI.Features.Security.Group; /// /// Lists tenant roles assigned to an Entra group. -/// Usage: txc tenant group role list --group <object-id> +/// Usage: txc security group role list --group <object-id> /// [CliReadOnly] [CliCommand( @@ -29,10 +29,10 @@ private async Task ExecuteListRolesAsync() try { var assignments = await GroupCommandSupport.ListRolesAsync(Profile, Group, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(assignments, TenantPrincipalCommandSupport.WriteRoleTable); + OutputFormatter.WriteList(assignments, SecurityPrincipalCommandSupport.WriteRoleTable); return ExitSuccess; } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) { return exitCode; } diff --git a/src/TALXIS.CLI.Features.Tenant/Group/GroupRoleRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Security/Group/GroupRoleRemoveCliCommand.cs similarity index 82% rename from src/TALXIS.CLI.Features.Tenant/Group/GroupRoleRemoveCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Group/GroupRoleRemoveCliCommand.cs index 4cfcbcf0..c600b3ed 100644 --- a/src/TALXIS.CLI.Features.Tenant/Group/GroupRoleRemoveCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Group/GroupRoleRemoveCliCommand.cs @@ -2,14 +2,14 @@ using Microsoft.Extensions.Logging; using TALXIS.CLI.Core; using TALXIS.CLI.Core.Abstractions; -using TALXIS.CLI.Features.Tenant; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Tenant.Group; +namespace TALXIS.CLI.Features.Security.Group; /// /// Removes a tenant role from an Entra group. -/// Usage: txc tenant group role remove --group <object-id> --role <name-or-guid> --yes +/// Usage: txc security group role remove --group <object-id> --role <name-or-guid> --yes /// [CliDestructive("Permanently removes the tenant role assignment from the group.")] [CliCommand( @@ -44,7 +44,7 @@ private async Task ExecuteRemoveRoleAsync() role = Role, }; - TenantPrincipalCommandSupport.WriteMutationResult(payload, () => + SecurityPrincipalCommandSupport.WriteMutationResult(payload, () => { #pragma warning disable TXC003 OutputWriter.WriteLine($"Role '{Role}' removed from group '{Group}'."); @@ -53,7 +53,7 @@ private async Task ExecuteRemoveRoleAsync() return ExitSuccess; } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) { return exitCode; } diff --git a/src/TALXIS.CLI.Features.Security/Role/RoleCliCommand.cs b/src/TALXIS.CLI.Features.Security/Role/RoleCliCommand.cs new file mode 100644 index 00000000..47345473 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/Role/RoleCliCommand.cs @@ -0,0 +1,25 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Security.Role; + +/// +/// Parent command for the tenant role catalog and Dataverse environment role catalog. +/// Usage: txc security role [list|get] +/// +[CliCommand( + Name = "role", + Description = "Browse the tenant role catalog when no environment is resolved, or the Dataverse security-role catalog when --environment is provided or resolved from the active connection.", + Children = new[] + { + typeof(RoleListCliCommand), + typeof(RoleGetCliCommand) + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class RoleCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Security/Role/RoleGetCliCommand.cs b/src/TALXIS.CLI.Features.Security/Role/RoleGetCliCommand.cs new file mode 100644 index 00000000..851ccbf0 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/Role/RoleGetCliCommand.cs @@ -0,0 +1,59 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.Role; + +[CliReadOnly] +[CliCommand( + Name = "get", + Description = "Get one tenant role when no environment is resolved. When --environment is provided or the active connection already targets an environment, get the Dataverse security role from that environment instead." +)] +public class RoleGetCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(RoleGetCliCommand)); + + [CliOption(Name = "--role", Description = "Tenant role name or role ID. With an environment scope, pass a Dataverse security role name or GUID instead.", Required = true)] + public string Role { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + return scope.HasEnvironment + ? await ExecuteEnvironmentGetAsync(scope).ConfigureAwait(false) + : await ExecuteTenantGetAsync().ConfigureAwait(false); + } + + private async Task ExecuteEnvironmentGetAsync(SecurityScopeContext scope) + { + var service = TxcServices.Get(); + + try + { + var row = await service.GetAsync(Profile, Role, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + if (row is null) + { + Logger.LogError("Role '{Role}' not found.", Role); + return ExitValidationError; + } + + OutputFormatter.WriteData(row, SecurityRoleCommandSupport.PrintEnvironmentRoleDetail); + return ExitSuccess; + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } + + private async Task ExecuteTenantGetAsync() + { + var role = await SecurityRoleCommandSupport.GetTenantRoleAsync(Profile, Role, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteData(role, RoleOutput.PrintDetail); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Security/Role/RoleListCliCommand.cs b/src/TALXIS.CLI.Features.Security/Role/RoleListCliCommand.cs new file mode 100644 index 00000000..56f490dd --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/Role/RoleListCliCommand.cs @@ -0,0 +1,45 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.Role; + +/// +/// Lists the tenant role catalog or the Dataverse environment security-role catalog, depending on scope. +/// Usage: txc security role list [--filter <name>] [--environment <id>] +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List tenant roles when no environment is resolved. When --environment is provided or the active connection already targets an environment, switch to that environment's Dataverse security-role catalog instead. The two catalogs are never combined." +)] +public class RoleListCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(RoleListCliCommand)); + + [CliOption(Name = "--filter", Description = "Show only tenant roles or Dataverse security roles whose name contains this value.", Required = false)] + public string? Filter { get; set; } + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + if (scope.HasEnvironment) + { + var service = TxcServices.Get(); + var rows = await service.ListAsync(Profile, NormalizeFilter(Filter), CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + OutputFormatter.WriteList(rows, SecurityRoleCommandSupport.PrintEnvironmentRoleList); + return ExitSuccess; + } + + var roles = await SecurityRoleCommandSupport.ListTenantRolesAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteList(roles, RoleOutput.PrintDetailList); + return ExitSuccess; + } + + private static string? NormalizeFilter(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/TALXIS.CLI.Features.Tenant/Role/RoleOutput.cs b/src/TALXIS.CLI.Features.Security/Role/RoleOutput.cs similarity index 92% rename from src/TALXIS.CLI.Features.Tenant/Role/RoleOutput.cs rename to src/TALXIS.CLI.Features.Security/Role/RoleOutput.cs index a3c3f9d1..04d33778 100644 --- a/src/TALXIS.CLI.Features.Tenant/Role/RoleOutput.cs +++ b/src/TALXIS.CLI.Features.Security/Role/RoleOutput.cs @@ -1,12 +1,12 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.PowerPlatform; -namespace TALXIS.CLI.Features.Tenant.Role; +namespace TALXIS.CLI.Features.Security.Role; internal static class RoleOutput { #pragma warning disable TXC003 - public static void PrintList(IReadOnlyList roles) + public static void PrintDetailList(IReadOnlyList roles) { if (roles.Count == 0) { diff --git a/src/TALXIS.CLI.Features.Security/Role/SecurityRoleCommandSupport.cs b/src/TALXIS.CLI.Features.Security/Role/SecurityRoleCommandSupport.cs new file mode 100644 index 00000000..d9770c6d --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/Role/SecurityRoleCommandSupport.cs @@ -0,0 +1,69 @@ +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Platform.PowerPlatform.Control; + +namespace TALXIS.CLI.Features.Security.Role; + +internal static class SecurityRoleCommandSupport +{ + public static async Task> ListTenantRolesAsync( + string? profile, + string? filter, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); + return await resolver.ListTenantRolesAsync(context.Connection, context.Credential, filter, ct).ConfigureAwait(false); + } + + public static async Task GetTenantRoleAsync( + string? profile, + string role, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); + return await resolver.GetTenantRoleAsync(context.Connection, context.Credential, role, ct).ConfigureAwait(false); + } + + public static void PrintEnvironmentRoleList(IReadOnlyList rows) + { +#pragma warning disable TXC003 + if (rows.Count == 0) + { + OutputWriter.WriteLine("No roles found."); + return; + } + + int nameWidth = Math.Clamp(rows.Max(r => r.Name.Length), 4, 50); + int businessUnitWidth = Math.Clamp(rows.Max(r => (r.BusinessUnitName ?? string.Empty).Length), 13, 40); + const int idWidth = 36; + + string header = + $"{"Name".PadRight(nameWidth)} | " + + $"{"Business Unit".PadRight(businessUnitWidth)} | " + + $"{"Id".PadRight(idWidth)}"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var row in rows) + { + OutputWriter.WriteLine( + $"{SecurityPrincipalCommandSupport.Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? "-", businessUnitWidth).PadRight(businessUnitWidth)} | " + + $"{row.Id}"); + } +#pragma warning restore TXC003 + } + + public static void PrintEnvironmentRoleDetail(DataverseRoleRecord role) + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Name: {role.Name}"); + OutputWriter.WriteLine($"Business Unit: {role.BusinessUnitName ?? "-"}"); + OutputWriter.WriteLine($"Id: {role.Id}"); +#pragma warning restore TXC003 + } +} diff --git a/src/TALXIS.CLI.Features.Security/SecurityCliCommand.cs b/src/TALXIS.CLI.Features.Security/SecurityCliCommand.cs new file mode 100644 index 00000000..30eecc8b --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/SecurityCliCommand.cs @@ -0,0 +1,17 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Security; + +[CliCommand( + Name = "security", + Description = "Discover and manage tenant-wide security resources, or switch to Dataverse environment-scoped security commands with --environment or an active environment connection.", + Children = new[] { typeof(Role.RoleCliCommand), typeof(ServicePrincipal.ServicePrincipalCliCommand), typeof(User.UserCliCommand), typeof(Team.TeamCliCommand), typeof(Group.GroupCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class SecurityCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Security/SecurityScopedCliCommand.cs b/src/TALXIS.CLI.Features.Security/SecurityScopedCliCommand.cs new file mode 100644 index 00000000..7927a5cf --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/SecurityScopedCliCommand.cs @@ -0,0 +1,13 @@ +using DotMake.CommandLine; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Security; + +public abstract class SecurityScopedCliCommand : ProfiledCliCommand +{ + [CliOption( + Name = "--environment", + Description = "Scope this operation to a Dataverse environment by environment ID. When omitted, txc uses the active environment connection if the resolved profile is already connected to one; otherwise the tenant-wide security implementation runs when this command supports it.", + Required = false)] + public Guid? Environment { get; set; } +} diff --git a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/AssemblyInfo.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/AssemblyInfo.cs similarity index 100% rename from src/TALXIS.CLI.Features.Tenant/ServicePrincipal/AssemblyInfo.cs rename to src/TALXIS.CLI.Features.Security/ServicePrincipal/AssemblyInfo.cs diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalCliCommand.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalCliCommand.cs similarity index 55% rename from src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalCliCommand.cs rename to src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalCliCommand.cs index b7e9c0db..1d694598 100644 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalCliCommand.cs @@ -1,16 +1,14 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; +namespace TALXIS.CLI.Features.Security.ServicePrincipal; /// -/// Parent command for Dataverse service-principal operations. -/// Service principals are represented by systemuser -/// rows with an application client ID. -/// Usage: txc environment service-principal [list|get|create|update|delete|role] +/// Parent command for Entra application discovery and Dataverse environment service-principal management. +/// Usage: txc security service-principal [list|get|create|update|delete|role] /// [CliCommand( Name = "service-principal", - Description = "Manage Dataverse service principals in the current environment.", + Description = "Discover Entra applications tenant-wide, or manage Dataverse environment service principals when --environment is provided or resolved from the active connection.", Children = new[] { typeof(ServicePrincipalListCliCommand), @@ -31,12 +29,12 @@ public void Run(CliContext context) } /// -/// Sub-resource for Dataverse security-role assignments on an service principal. -/// Usage: txc environment service-principal role [list|add|remove] +/// Sub-resource for tenant-wide and Dataverse security-role assignments on a service principal. +/// Usage: txc security service-principal role [list|add|remove] /// [CliCommand( Name = "role", - Description = "Manage Dataverse security roles assigned to an service principal.", + Description = "List, add, or remove tenant admin roles and Dataverse security roles for a service principal. With an environment scope, role list shows tenant admin roles and environment security roles in separate sections.", Children = new[] { typeof(ServicePrincipalRoleListCliCommand), diff --git a/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalCommandSupport.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalCommandSupport.cs new file mode 100644 index 00000000..d7a1344e --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalCommandSupport.cs @@ -0,0 +1,307 @@ +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Platform.PowerPlatform.Control; +using TALXIS.CLI.Platform.PowerPlatform.Control.Graph; + +namespace TALXIS.CLI.Features.Security.ServicePrincipal; + +internal static class ServicePrincipalCommandSupport +{ + public static async Task> ListServicePrincipalsAsync( + string? profile, + string? filter, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var graph = TxcServices.Get(); + return await graph.ListServicePrincipalsAsync( + context.Connection, + context.Credential, + BuildListFilter(filter), + top: 100, + ct).ConfigureAwait(false); + } + + public static async Task GetServicePrincipalAsync( + string? profile, + string app, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var graph = TxcServices.Get(); + var matches = await graph.ListServicePrincipalsAsync( + context.Connection, + context.Credential, + BuildExactAppFilter(app), + top: 25, + ct).ConfigureAwait(false); + + var normalized = app.Trim(); + var exactMatches = matches.Where(candidate => MatchesApplication(candidate, normalized)).ToList(); + + if (exactMatches.Count == 0) + throw new TenantPrincipalNotFoundException(PowerPlatformPrincipalType.ApplicationUser, app); + + if (exactMatches.Count > 1) + { + throw new TenantPrincipalAmbiguousException( + PowerPlatformPrincipalType.ApplicationUser, + app, + exactMatches.Select(FormatAppCandidate)); + } + + return exactMatches[0]; + } + + public static async Task> ListTenantAssignmentsAsync( + string? profile, + string app, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); + return await resolver.ListAssignmentsAsync( + context.Connection, + context.Credential, + PowerPlatformPrincipalType.ApplicationUser, + app, + ct).ConfigureAwait(false); + } + + public static async Task AddTenantAssignmentAsync( + string? profile, + string app, + string role, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); + await resolver.AddAssignmentAsync( + context.Connection, + context.Credential, + PowerPlatformPrincipalType.ApplicationUser, + app, + role, + ct).ConfigureAwait(false); + } + + public static async Task RemoveTenantAssignmentAsync( + string? profile, + string app, + string role, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); + await resolver.RemoveAssignmentAsync( + context.Connection, + context.Credential, + PowerPlatformPrincipalType.ApplicationUser, + app, + role, + ct).ConfigureAwait(false); + } + + internal static bool TryResolveEnabledState( + bool enable, + bool disable, + ILogger logger, + out bool enabled) + { + if (enable == disable) + { + logger.LogError("Specify exactly one of --enable or --disable."); + enabled = false; + return false; + } + + enabled = enable; + return true; + } + + internal static void WriteServicePrincipalDetails(DataverseServicePrincipalRecord app) + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"System User ID: {app.Id}"); + OutputWriter.WriteLine($"Application ID: {app.ApplicationId}"); + OutputWriter.WriteLine($"Name: {app.FullName ?? "-"}"); + OutputWriter.WriteLine($"State: {(app.IsDisabled ? "Disabled" : "Enabled")}"); + OutputWriter.WriteLine($"Business Unit: {app.BusinessUnitName ?? "-"}"); + OutputWriter.WriteLine($"Business Unit ID: {(app.BusinessUnitId?.ToString() ?? "-")}"); + OutputWriter.WriteLine($"Entra Object ID: {(app.AzureActiveDirectoryObjectId?.ToString() ?? "-")}"); +#pragma warning restore TXC003 + } + + internal static void WriteEnvironmentServicePrincipalTable(IReadOnlyList rows) + { +#pragma warning disable TXC003 + if (rows.Count == 0) + { + OutputWriter.WriteLine("No service principals found."); + return; + } + + int nameWidth = Math.Clamp(rows.Max(static r => (r.FullName ?? string.Empty).Length), 4, 36); + int stateWidth = 8; + int businessUnitWidth = Math.Clamp(rows.Max(static r => (r.BusinessUnitName ?? string.Empty).Length), 13, 36); + + string header = + $"{"System User ID".PadRight(36)} | " + + $"{"Application ID".PadRight(36)} | " + + $"{"Name".PadRight(nameWidth)} | " + + $"{"State".PadRight(stateWidth)} | " + + $"{"Business Unit".PadRight(businessUnitWidth)}"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var row in rows) + { + OutputWriter.WriteLine( + $"{row.Id} | " + + $"{row.ApplicationId} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.FullName ?? string.Empty, nameWidth).PadRight(nameWidth)} | " + + $"{(row.IsDisabled ? "Disabled" : "Enabled").PadRight(stateWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? string.Empty, businessUnitWidth).PadRight(businessUnitWidth)}"); + } +#pragma warning restore TXC003 + } + + internal static void WriteEnvironmentRoleTable(IReadOnlyList rows) + { +#pragma warning disable TXC003 + if (rows.Count == 0) + { + OutputWriter.WriteLine("No security roles assigned."); + return; + } + + int nameWidth = Math.Clamp(rows.Max(static r => r.Name.Length), 4, 48); + int businessUnitWidth = Math.Clamp(rows.Max(static r => (r.BusinessUnitName ?? string.Empty).Length), 13, 36); + + string header = + $"{"Role ID".PadRight(36)} | " + + $"{"Name".PadRight(nameWidth)} | " + + $"{"Business Unit".PadRight(businessUnitWidth)}"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var row in rows) + { + OutputWriter.WriteLine( + $"{row.Id} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? string.Empty, businessUnitWidth).PadRight(businessUnitWidth)}"); + } +#pragma warning restore TXC003 + } + + internal static void WriteCreateResult( + DataverseServicePrincipalRecord app, + IReadOnlyList assignedRoles, + IReadOnlyList failures) + { + var payload = new + { + status = failures.Count == 0 ? "created" : "partial", + servicePrincipal = app, + assignedRoles, + failedRoles = failures.Select(static failure => new + { + role = failure.Role, + error = failure.Message, + }).ToArray(), + }; + + OutputFormatter.WriteData(payload, _ => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine(failures.Count == 0 + ? "Service principal created." + : "Service principal created, but one or more role assignments failed."); + WriteServicePrincipalDetails(app); + + if (assignedRoles.Count > 0) + { + OutputWriter.WriteLine(); + OutputWriter.WriteLine($"Assigned roles ({assignedRoles.Count}):"); + foreach (var role in assignedRoles) + OutputWriter.WriteLine($" - {role}"); + } + + if (failures.Count > 0) + { + OutputWriter.WriteLine(); + OutputWriter.WriteLine($"Role assignment failures ({failures.Count}):"); + foreach (var failure in failures) + OutputWriter.WriteLine($" - {failure.Role}: {failure.Message}"); + } +#pragma warning restore TXC003 + }); + } + + internal static void WriteServicePrincipalTable(IReadOnlyList rows) + { +#pragma warning disable TXC003 + if (rows.Count == 0) + { + OutputWriter.WriteLine("No tenant service principals found."); + return; + } + + const int objectIdWidth = 36; + const int appIdWidth = 36; + int displayNameWidth = Math.Clamp(rows.Max(r => (r.DisplayName ?? string.Empty).Length), 12, 48); + + string header = + $"{"Application ID".PadRight(appIdWidth)} | " + + $"{"Object ID".PadRight(objectIdWidth)} | " + + "Display Name"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length + displayNameWidth)); + + foreach (var row in rows) + { + OutputWriter.WriteLine( + $"{(row.AppId?.ToString() ?? "-").PadRight(appIdWidth)} | " + + $"{row.Id} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.DisplayName ?? string.Empty, displayNameWidth)}"); + } +#pragma warning restore TXC003 + } + + internal static void WriteServicePrincipalDetail(GraphServicePrincipal app) + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Application ID: {(app.AppId?.ToString() ?? "-")}"); + OutputWriter.WriteLine($"Object ID: {app.Id}"); + OutputWriter.WriteLine($"Display Name: {app.DisplayName ?? "-"}"); +#pragma warning restore TXC003 + } + + internal static void WriteMutationResult(T payload, Action textRenderer) + => OutputFormatter.WriteData(payload, _ => textRenderer()); + + private static string? BuildListFilter(string? filter) + { + if (string.IsNullOrWhiteSpace(filter)) + return null; + + return $"startswith(displayName,'{GraphODataFilterSupport.EscapeODataString(filter.Trim())}')"; + } + + private static string BuildExactAppFilter(string app) + => GraphODataFilterSupport.BuildIdentifierFilter(app, ["appId", "id"], ["displayName"]); + + private static bool MatchesApplication(GraphServicePrincipal principal, string input) + => principal.Id.ToString().Equals(input, StringComparison.OrdinalIgnoreCase) + || (principal.AppId?.ToString().Equals(input, StringComparison.OrdinalIgnoreCase) ?? false) + || string.Equals(principal.DisplayName, input, StringComparison.OrdinalIgnoreCase); + + private static string FormatAppCandidate(GraphServicePrincipal principal) + => $"{principal.DisplayName ?? "-"} (appId: {principal.AppId?.ToString() ?? "-"}, id: {principal.Id})"; +} + +internal sealed record ServicePrincipalRoleAssignmentFailure(string Role, string Message, bool IsValidationError); diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalCreateCliCommand.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalCreateCliCommand.cs similarity index 69% rename from src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalCreateCliCommand.cs rename to src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalCreateCliCommand.cs index 622c359e..cbcd8e2c 100644 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalCreateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalCreateCliCommand.cs @@ -3,20 +3,21 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; +namespace TALXIS.CLI.Features.Security.ServicePrincipal; /// -/// Creates a Dataverse service principal directly in the environment. -/// Usage: txc environment service-principal create --service-principal <entra-client-id> [--business-unit <name-or-guid>] [--role <csv>] +/// Creates a Dataverse service principal directly in the resolved environment. +/// Usage: txc security service-principal create --service-principal <entra-client-id> [--business-unit <name-or-guid>] [--role <csv>] [--environment <id>] /// [CliIdempotent] [CliCommand( Name = "create", - Description = "Create a Dataverse service principal from an existing Entra app registration. This creates only the environment-side service principal record, so the app registration itself must already exist. No prior environment-side registration step is required. Use --role with one comma-separated value to assign initial roles. If the user is created but one or more role assignments fail, txc reports the created user, lists the failed roles, and exits non-zero so you can retry just those role assignments." + Description = "Create a Dataverse service principal from an existing Entra app registration. This command requires --environment or an active environment connection because there is no tenant-wide creation equivalent. Use --role with a comma-separated list to assign initial Dataverse security roles in the same step." )] -public class ServicePrincipalCreateCliCommand : ProfiledCliCommand +public class ServicePrincipalCreateCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalCreateCliCommand)); @@ -31,7 +32,7 @@ public class ServicePrincipalCreateCliCommand : ProfiledCliCommand protected override Task ExecuteAsync() { - if (!ServicePrincipalCommandSupport.TryParseRoleIdentifiers(Role, Logger, out var requestedRoles)) + if (!SecurityPrincipalCommandSupport.TryParseRoleIdentifiers(Role, Logger, out var requestedRoles)) return Task.FromResult(ExitValidationError); return ExecuteCreateAsync(requestedRoles); @@ -39,13 +40,16 @@ protected override Task ExecuteAsync() private async Task ExecuteCreateAsync(IReadOnlyList requestedRoles) { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security service-principal create", CancellationToken.None).ConfigureAwait(false); + try { var service = TxcServices.Get(); var app = await service.CreateAsync( Profile, new DataverseServicePrincipalCreateOptions(ServicePrincipal, BusinessUnit, Array.Empty()), - CancellationToken.None).ConfigureAwait(false); + CancellationToken.None, + scope.EnvironmentId).ConfigureAwait(false); if (requestedRoles.Count == 0) { @@ -57,7 +61,7 @@ private async Task ExecuteCreateAsync(IReadOnlyList requestedRoles) var failures = new List(); foreach (var role in requestedRoles) - await TryAssignRoleAsync(service, app, role, assignedRoles, failures).ConfigureAwait(false); + await TryAssignRoleAsync(service, scope.EnvironmentId, app, role, assignedRoles, failures).ConfigureAwait(false); ServicePrincipalCommandSupport.WriteCreateResult(app, assignedRoles, failures); @@ -71,7 +75,7 @@ private async Task ExecuteCreateAsync(IReadOnlyList requestedRoles) ? ExitValidationError : ExitError; } - catch (Exception ex) when (ServicePrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) { return exitCode; } @@ -79,6 +83,7 @@ private async Task ExecuteCreateAsync(IReadOnlyList requestedRoles) private async Task TryAssignRoleAsync( IDataverseServicePrincipalService service, + Guid? environmentId, DataverseServicePrincipalRecord app, string role, ICollection assignedRoles, @@ -86,7 +91,7 @@ private async Task TryAssignRoleAsync( { try { - await service.AddRoleAsync(Profile, app.Id.ToString(), role, CancellationToken.None).ConfigureAwait(false); + await service.AddRoleAsync(Profile, app.Id.ToString(), role, CancellationToken.None, environmentId).ConfigureAwait(false); assignedRoles.Add(role); } catch (Exception ex) when (ex is DataverseAmbiguousMatchException or ArgumentException or InvalidOperationException) diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalDeleteCliCommand.cs similarity index 62% rename from src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalDeleteCliCommand.cs rename to src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalDeleteCliCommand.cs index a5721aab..9315f383 100644 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalDeleteCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalDeleteCliCommand.cs @@ -4,20 +4,21 @@ using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; +namespace TALXIS.CLI.Features.Security.ServicePrincipal; /// /// Hard-deletes a Dataverse service principal. -/// Usage: txc environment service-principal delete --service-principal <client-id-or-guid> --yes +/// Usage: txc security service-principal delete --service-principal <client-id-or-guid> --yes [--environment <id>] /// -[CliDestructive("Permanently deletes the Dataverse service principal from the environment.")] +[CliDestructive("Permanently deletes the Dataverse service principal from the resolved environment.")] [CliCommand( Name = "delete", - Description = "Hard-delete a Dataverse service principal. The record must already be disabled before Dataverse will allow the delete." + Description = "Hard-delete a Dataverse service principal. This command requires --environment or an active environment connection because there is no tenant-wide delete equivalent. The record must already be disabled before Dataverse will allow the delete." )] -public class ServicePrincipalDeleteCliCommand : ProfiledCliCommand, IDestructiveCommand +public class ServicePrincipalDeleteCliCommand : SecurityScopedCliCommand, IDestructiveCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalDeleteCliCommand)); @@ -31,23 +32,20 @@ public class ServicePrincipalDeleteCliCommand : ProfiledCliCommand, IDestructive private async Task ExecuteDeleteAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security service-principal delete", CancellationToken.None).ConfigureAwait(false); + try { var service = TxcServices.Get(); - var existing = await service.GetAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); + var existing = await service.GetAsync(Profile, ServicePrincipal, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); if (existing is null) { Logger.LogError("Service principal '{ServicePrincipal}' not found.", ServicePrincipal); return ExitValidationError; } - await service.DeleteAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); - - var payload = new - { - status = "deleted", - servicePrincipal = existing, - }; + await service.DeleteAsync(Profile, ServicePrincipal, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + var payload = new { status = "deleted", environmentId = scope.EnvironmentId, servicePrincipal = existing }; ServicePrincipalCommandSupport.WriteMutationResult(payload, () => { @@ -59,7 +57,7 @@ private async Task ExecuteDeleteAsync() return ExitSuccess; } - catch (Exception ex) when (ServicePrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) { return exitCode; } diff --git a/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalGetCliCommand.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalGetCliCommand.cs new file mode 100644 index 00000000..30bc3ae7 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalGetCliCommand.cs @@ -0,0 +1,65 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.ServicePrincipal; + +[CliReadOnly] +[CliCommand( + Name = "get", + Description = "Get one Entra application by client ID, object ID, or exact display name when no environment is resolved. When --environment is provided or the active connection already targets an environment, get the Dataverse service principal by system-user GUID or application client ID instead." +)] +public class ServicePrincipalGetCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalGetCliCommand)); + + [CliOption(Name = "--service-principal", Description = "Application client ID, service principal object ID, or exact display name. With an environment scope, a Dataverse system-user GUID is also accepted.", Required = true)] + public string ServicePrincipal { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + return scope.HasEnvironment + ? await ExecuteEnvironmentGetAsync(scope).ConfigureAwait(false) + : await ExecuteTenantGetAsync().ConfigureAwait(false); + } + + private async Task ExecuteEnvironmentGetAsync(SecurityScopeContext scope) + { + try + { + var service = TxcServices.Get(); + var app = await service.GetAsync(Profile, ServicePrincipal, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + if (app is null) + { + Logger.LogError("Service principal '{ServicePrincipal}' not found.", ServicePrincipal); + return ExitValidationError; + } + + OutputFormatter.WriteData(app, ServicePrincipalCommandSupport.WriteServicePrincipalDetails); + return ExitSuccess; + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } + + private async Task ExecuteTenantGetAsync() + { + try + { + var app = await ServicePrincipalCommandSupport.GetServicePrincipalAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteData(app, ServicePrincipalCommandSupport.WriteServicePrincipalDetail); + return ExitSuccess; + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } +} diff --git a/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalListCliCommand.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalListCliCommand.cs new file mode 100644 index 00000000..e326ef07 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalListCliCommand.cs @@ -0,0 +1,60 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.ServicePrincipal; + +/// +/// Lists Entra applications tenant-wide, or Dataverse service principals when an environment scope is resolved. +/// Usage: txc security service-principal list [--filter <name>] [--enabled|--disabled|--all] [--environment <id>] +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List Entra applications when no environment is resolved. When --environment is provided or the active connection already targets an environment, list Dataverse service principals instead; in that mode --enabled, --disabled, and --all control the Dataverse service-principal state filter." +)] +public class ServicePrincipalListCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalListCliCommand)); + + [CliOption(Name = "--filter", Description = "Show only Entra applications whose display name starts with this value. When an environment scope is resolved, this option is ignored because Dataverse service-principal listing currently exposes state filters instead.", Required = false)] + public string? Filter { get; set; } + + [CliOption(Name = "--enabled", Description = "When an environment scope is resolved, list enabled Dataverse service principals only. This is the default when no Dataverse state flag is supplied.", Required = false)] + public bool Enabled { get; set; } + + [CliOption(Name = "--disabled", Description = "When an environment scope is resolved, list disabled Dataverse service principals only.", Required = false)] + public bool Disabled { get; set; } + + [CliOption(Name = "--all", Description = "When an environment scope is resolved, list both enabled and disabled Dataverse service principals.", Required = false)] + public bool All { get; set; } + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + if (scope.HasEnvironment) + { + if (!SecurityPrincipalCommandSupport.TryResolveStateFilter(Enabled, Disabled, All, Logger, out var filter)) + return ExitValidationError; + + var service = TxcServices.Get(); + var rows = await service.ListAsync(Profile, filter, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + OutputFormatter.WriteList(rows, ServicePrincipalCommandSupport.WriteEnvironmentServicePrincipalTable); + return ExitSuccess; + } + + if (Enabled || Disabled || All) + { + Logger.LogError("--enabled, --disabled, and --all require --environment or an active environment connection."); + return ExitValidationError; + } + + var applications = await ServicePrincipalCommandSupport.ListServicePrincipalsAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteList(applications, ServicePrincipalCommandSupport.WriteServicePrincipalTable); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalRoleAddCliCommand.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalRoleAddCliCommand.cs new file mode 100644 index 00000000..7bd55e0d --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalRoleAddCliCommand.cs @@ -0,0 +1,88 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.ServicePrincipal; + +[CliIdempotent] +[CliCommand( + Name = "add", + Description = "Assign a tenant admin role when no environment is resolved. When --environment is provided or the active connection already targets an environment, assign a Dataverse security role in that environment instead." +)] +public class ServicePrincipalRoleAddCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalRoleAddCliCommand)); + + [CliOption(Name = "--service-principal", Description = "Application client ID, service principal object ID, or exact display name. With an environment scope, a Dataverse system-user GUID is also accepted.", Required = true)] + public string ServicePrincipal { get; set; } = null!; + + [CliOption(Name = "--role", Description = "Tenant role name or GUID. With an environment scope, pass a Dataverse security role name or GUID instead.", Required = true)] + public string Role { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + return scope.HasEnvironment + ? await ExecuteEnvironmentAddAsync(scope).ConfigureAwait(false) + : await ExecuteTenantAddAsync().ConfigureAwait(false); + } + + private async Task ExecuteTenantAddAsync() + { + try + { + await ServicePrincipalCommandSupport.AddTenantAssignmentAsync(Profile, ServicePrincipal, Role, CancellationToken.None).ConfigureAwait(false); + var payload = new { status = "role-added", servicePrincipal = ServicePrincipal, role = Role }; + ServicePrincipalCommandSupport.WriteMutationResult(payload, () => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Role '{Role}' assigned to service principal '{ServicePrincipal}'."); +#pragma warning restore TXC003 + }); + return ExitSuccess; + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } + + private async Task ExecuteEnvironmentAddAsync(SecurityScopeContext scope) + { + try + { + var service = TxcServices.Get(); + var existingRoles = await service.ListRolesAsync(Profile, ServicePrincipal, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + if (existingRoles.Any(r => SecurityPrincipalCommandSupport.IsRoleMatch(r, Role))) + { + ServicePrincipalCommandSupport.WriteMutationResult( + new { status = "unchanged", servicePrincipal = ServicePrincipal, role = Role, environmentId = scope.EnvironmentId }, + () => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Role '{Role}' is already assigned to service principal '{ServicePrincipal}'."); +#pragma warning restore TXC003 + }); + return ExitSuccess; + } + + await service.AddRoleAsync(Profile, ServicePrincipal, Role, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + var payload = new { status = "role-added", servicePrincipal = ServicePrincipal, role = Role, environmentId = scope.EnvironmentId }; + ServicePrincipalCommandSupport.WriteMutationResult(payload, () => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Role '{Role}' assigned to service principal '{ServicePrincipal}'."); +#pragma warning restore TXC003 + }); + return ExitSuccess; + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } +} diff --git a/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalRoleListCliCommand.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalRoleListCliCommand.cs new file mode 100644 index 00000000..bcc1c916 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalRoleListCliCommand.cs @@ -0,0 +1,55 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.ServicePrincipal; + +/// +/// Lists role assignments for a service principal. Without an environment scope this shows tenant admin roles only; with an environment scope it shows tenant admin roles and Dataverse environment security roles in separate sections. +/// Usage: txc security service-principal role list --service-principal <client-id-or-object-id> [--environment <id>] +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List the service principal's tenant admin roles when no environment is resolved. When --environment is provided or the active connection already targets an environment, also list the Dataverse security roles assigned in that environment under a separate labeled section." +)] +public class ServicePrincipalRoleListCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalRoleListCliCommand)); + + [CliOption(Name = "--service-principal", Description = "Application client ID, service principal object ID, or exact display name. With an environment scope, a Dataverse system-user GUID is also accepted.", Required = true)] + public string ServicePrincipal { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + var tenantAssignments = await ServicePrincipalCommandSupport.ListTenantAssignmentsAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); + if (!scope.HasEnvironment) + { + OutputFormatter.WriteList(tenantAssignments, SecurityPrincipalCommandSupport.WriteRoleTable); + return ExitSuccess; + } + + var service = TxcServices.Get(); + var environmentAssignments = await service.ListRolesAsync(Profile, ServicePrincipal, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + var payload = new + { + tenantAdminRoles = tenantAssignments, + environmentId = scope.EnvironmentId, + environmentDisplayName = scope.EnvironmentDisplayName, + environmentSecurityRoles = environmentAssignments, + }; + + OutputFormatter.WriteData(payload, _ => + SecurityPrincipalCommandSupport.WriteCombinedRoleSections( + tenantAssignments, + environmentAssignments, + scope, + ServicePrincipalCommandSupport.WriteEnvironmentRoleTable)); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalRoleRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalRoleRemoveCliCommand.cs new file mode 100644 index 00000000..63920220 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalRoleRemoveCliCommand.cs @@ -0,0 +1,78 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.ServicePrincipal; + +[CliDestructive("Removes the selected role assignment. With an environment scope this removes a Dataverse security role; otherwise it removes a tenant admin role.")] +[CliCommand( + Name = "remove", + Description = "Remove a tenant admin role when no environment is resolved. When --environment is provided or the active connection already targets an environment, remove a Dataverse security role from that environment instead." +)] +public class ServicePrincipalRoleRemoveCliCommand : SecurityScopedCliCommand, IDestructiveCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalRoleRemoveCliCommand)); + + [CliOption(Name = "--yes", Description = "Skip interactive confirmation.", Required = false)] + public bool Yes { get; set; } + + [CliOption(Name = "--service-principal", Description = "Application client ID, service principal object ID, or exact display name. With an environment scope, a Dataverse system-user GUID is also accepted.", Required = true)] + public string ServicePrincipal { get; set; } = null!; + + [CliOption(Name = "--role", Description = "Tenant role name or GUID. With an environment scope, pass a Dataverse security role name or GUID instead.", Required = true)] + public string Role { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + return scope.HasEnvironment + ? await ExecuteEnvironmentRemoveAsync(scope).ConfigureAwait(false) + : await ExecuteTenantRemoveAsync().ConfigureAwait(false); + } + + private async Task ExecuteTenantRemoveAsync() + { + try + { + await ServicePrincipalCommandSupport.RemoveTenantAssignmentAsync(Profile, ServicePrincipal, Role, CancellationToken.None).ConfigureAwait(false); + var payload = new { status = "role-removed", servicePrincipal = ServicePrincipal, role = Role }; + ServicePrincipalCommandSupport.WriteMutationResult(payload, () => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Role '{Role}' removed from service principal '{ServicePrincipal}'."); +#pragma warning restore TXC003 + }); + return ExitSuccess; + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } + + private async Task ExecuteEnvironmentRemoveAsync(SecurityScopeContext scope) + { + try + { + var service = TxcServices.Get(); + await service.RemoveRoleAsync(Profile, ServicePrincipal, Role, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + var payload = new { status = "role-removed", servicePrincipal = ServicePrincipal, role = Role, environmentId = scope.EnvironmentId }; + ServicePrincipalCommandSupport.WriteMutationResult(payload, () => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Role '{Role}' removed from service principal '{ServicePrincipal}'."); +#pragma warning restore TXC003 + }); + return ExitSuccess; + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } +} diff --git a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalUpdateCliCommand.cs b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalUpdateCliCommand.cs similarity index 65% rename from src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalUpdateCliCommand.cs rename to src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalUpdateCliCommand.cs index 1795f8f4..353586f2 100644 --- a/src/TALXIS.CLI.Features.Environment/ServicePrincipal/ServicePrincipalUpdateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/ServicePrincipal/ServicePrincipalUpdateCliCommand.cs @@ -3,20 +3,21 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.ServicePrincipal; +namespace TALXIS.CLI.Features.Security.ServicePrincipal; /// /// Enables or disables a Dataverse service principal. -/// Usage: txc environment service-principal update --service-principal <client-id-or-guid> [--enable|--disable] +/// Usage: txc security service-principal update --service-principal <client-id-or-guid> [--enable|--disable] [--environment <id>] /// [CliIdempotent] [CliCommand( Name = "update", - Description = "Enable or disable a Dataverse service principal. Specify exactly one of --enable or --disable." + Description = "Enable or disable a Dataverse service principal. This command requires --environment or an active environment connection because there is no tenant-wide service-principal state mutation equivalent. Specify exactly one of --enable or --disable." )] -public class ServicePrincipalUpdateCliCommand : ProfiledCliCommand +public class ServicePrincipalUpdateCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalUpdateCliCommand)); @@ -39,17 +40,15 @@ protected override Task ExecuteAsync() private async Task ExecuteUpdateAsync(bool enabled) { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security service-principal update", CancellationToken.None).ConfigureAwait(false); + try { var service = TxcServices.Get(); - await service.UpdateEnabledStateAsync(Profile, ServicePrincipal, enabled, CancellationToken.None).ConfigureAwait(false); + await service.UpdateEnabledStateAsync(Profile, ServicePrincipal, enabled, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); - var updated = await service.GetAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); - var payload = new - { - status = enabled ? "enabled" : "disabled", - servicePrincipal = updated, - }; + var updated = await service.GetAsync(Profile, ServicePrincipal, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + var payload = new { status = enabled ? "enabled" : "disabled", environmentId = scope.EnvironmentId, servicePrincipal = updated }; ServicePrincipalCommandSupport.WriteMutationResult(payload, () => { @@ -64,7 +63,7 @@ private async Task ExecuteUpdateAsync(bool enabled) return ExitSuccess; } - catch (Exception ex) when (ServicePrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) { return exitCode; } diff --git a/src/TALXIS.CLI.Features.Security/TALXIS.CLI.Features.Security.csproj b/src/TALXIS.CLI.Features.Security/TALXIS.CLI.Features.Security.csproj new file mode 100644 index 00000000..3db15965 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/TALXIS.CLI.Features.Security.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamCliCommand.cs similarity index 66% rename from src/TALXIS.CLI.Features.Environment/Team/TeamCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamCliCommand.cs index 162b69a9..5c957aba 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamCliCommand.cs @@ -1,14 +1,14 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; /// -/// Parent command for Dataverse team operations. -/// Usage: txc environment team [list|get|create|delete|member|role] +/// Parent command for Dataverse team operations under txc security. +/// Usage: txc security team [list|get|create|delete|member|role] /// [CliCommand( Name = "team", - Description = "Manage Dataverse teams, team membership, and team role assignments.", + Description = "Manage Dataverse teams, team membership, and team role assignments. Every team command requires --environment or an active environment connection because teams have no tenant-wide security equivalent.", Children = new[] { typeof(TeamListCliCommand), @@ -28,13 +28,9 @@ public void Run(CliContext context) } } -/// -/// Sub-resource for Dataverse team membership operations. -/// Usage: txc environment team member [list|add|remove] -/// [CliCommand( Name = "member", - Description = "List, add, or remove direct team members.", + Description = "List, add, or remove direct Dataverse team members in the resolved environment.", Children = new[] { typeof(TeamMemberListCliCommand), @@ -51,13 +47,9 @@ public void Run(CliContext context) } } -/// -/// Sub-resource for Dataverse team role assignment operations. -/// Usage: txc environment team role [list|add|remove] -/// [CliCommand( Name = "role", - Description = "List, add, or remove security roles assigned to a team.", + Description = "List, add, or remove Dataverse security roles assigned to a team in the resolved environment.", Children = new[] { typeof(TeamRoleListCliCommand), diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamCommandSupport.cs b/src/TALXIS.CLI.Features.Security/Team/TeamCommandSupport.cs similarity index 85% rename from src/TALXIS.CLI.Features.Environment/Team/TeamCommandSupport.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamCommandSupport.cs index 7e5634cd..9bc50198 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamCommandSupport.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamCommandSupport.cs @@ -2,7 +2,7 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; internal static class TeamCommandSupport { @@ -127,10 +127,10 @@ public static void WriteTeamList(IReadOnlyList rows) foreach (var row in rows) { OutputWriter.WriteLine( - $"{EnvironmentPrincipalCommandSupport.Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(ToCliValue(row.TeamType), typeWidth).PadRight(typeWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(ToCliValue(row.MembershipType) ?? "-", membershipWidth).PadRight(membershipWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? "-", businessUnitWidth).PadRight(businessUnitWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(ToCliValue(row.TeamType), typeWidth).PadRight(typeWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(ToCliValue(row.MembershipType) ?? "-", membershipWidth).PadRight(membershipWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? "-", businessUnitWidth).PadRight(businessUnitWidth)} | " + $"{(row.IsDefault ? "true" : "false").PadRight(defaultWidth)} | " + $"{(row.IsSystemManaged ? "true" : "false").PadRight(managedWidth)}"); } @@ -166,10 +166,10 @@ public static void WriteMemberList(IReadOnlyList rows) foreach (var row in rows) { OutputWriter.WriteLine( - $"{EnvironmentPrincipalCommandSupport.Truncate(row.FullName ?? "-", nameWidth).PadRight(nameWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.UserPrincipalName ?? "-", upnWidth).PadRight(upnWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.PrimaryEmailAddress ?? "-", emailWidth).PadRight(emailWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? "-", businessUnitWidth).PadRight(businessUnitWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.FullName ?? "-", nameWidth).PadRight(nameWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.UserPrincipalName ?? "-", upnWidth).PadRight(upnWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.PrimaryEmailAddress ?? "-", emailWidth).PadRight(emailWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? "-", businessUnitWidth).PadRight(businessUnitWidth)} | " + $"{(row.IsDisabled ? "true" : "false").PadRight(disabledWidth)}"); } #pragma warning restore TXC003 @@ -198,8 +198,8 @@ public static void WriteRoleList(IReadOnlyList rows) foreach (var row in rows) { OutputWriter.WriteLine( - $"{EnvironmentPrincipalCommandSupport.Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + - $"{EnvironmentPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? "-", businessUnitWidth).PadRight(businessUnitWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? "-", businessUnitWidth).PadRight(businessUnitWidth)} | " + row.Id); } #pragma warning restore TXC003 diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamCreateCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamCreateCliCommand.cs similarity index 83% rename from src/TALXIS.CLI.Features.Environment/Team/TeamCreateCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamCreateCliCommand.cs index 2fabd5de..c62c53a4 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamCreateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamCreateCliCommand.cs @@ -3,20 +3,17 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Creates a Dataverse team. -/// Usage: txc environment team create --name <name> --type owner|access|aad-security-group|aad-office-group [--aad-object-id <guid>] [--membership-type members-and-guests|members|owners|guests] [--business-unit <name-or-guid>] -/// [CliIdempotent] [CliCommand( Name = "create", - Description = "Create a Dataverse team. Types: owner, access, aad-security-group, aad-office-group. AAD-backed types require --aad-object-id and can optionally use --membership-type; owner/access teams must not use those AAD-only options." + Description = "Create a Dataverse team in the resolved environment. Types: owner, access, aad-security-group, aad-office-group. AAD-backed types require --aad-object-id and can optionally use --membership-type; owner/access teams must not use those AAD-only options. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamCreateCliCommand : ProfiledCliCommand +public class TeamCreateCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamCreateCliCommand)); @@ -83,11 +80,12 @@ protected override Task ExecuteAsync() private async Task ExecuteCreateAsync(DataverseTeamCreateOptions options) { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team create", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); try { - var team = await service.CreateAsync(Profile, options, CancellationToken.None).ConfigureAwait(false); + var team = await service.CreateAsync(Profile, options, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); OutputFormatter.WriteData(team, TeamCommandSupport.WriteTeamDetail); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamDeleteCliCommand.cs similarity index 75% rename from src/TALXIS.CLI.Features.Environment/Team/TeamDeleteCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamDeleteCliCommand.cs index d3b143a9..bcf2a249 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamDeleteCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamDeleteCliCommand.cs @@ -4,20 +4,17 @@ using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Deletes a Dataverse team. -/// Usage: txc environment team delete --team <name-or-guid> --yes -/// [CliDestructive("Permanently deletes the Dataverse team.")] [CliCommand( Name = "delete", - Description = "Delete a Dataverse team by exact name or GUID. This is destructive." + Description = "Delete a Dataverse team by exact name or GUID in the resolved environment. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamDeleteCliCommand : ProfiledCliCommand, IDestructiveCommand +public class TeamDeleteCliCommand : SecurityScopedCliCommand, IDestructiveCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamDeleteCliCommand)); @@ -31,11 +28,12 @@ public class TeamDeleteCliCommand : ProfiledCliCommand, IDestructiveCommand private async Task ExecuteDeleteAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team delete", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); try { - await service.DeleteAsync(Profile, Team, CancellationToken.None).ConfigureAwait(false); + await service.DeleteAsync(Profile, Team, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); OutputFormatter.WriteResult("succeeded", $"Dataverse team '{Team}' deleted."); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamGetCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamGetCliCommand.cs similarity index 72% rename from src/TALXIS.CLI.Features.Environment/Team/TeamGetCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamGetCliCommand.cs index a4c6e8ed..d981d915 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamGetCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamGetCliCommand.cs @@ -3,20 +3,17 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Gets details for a Dataverse team. -/// Usage: txc environment team get --team <name-or-guid> -/// [CliReadOnly] [CliCommand( Name = "get", - Description = "Get details for a Dataverse team by exact name or GUID." + Description = "Get details for a Dataverse team by exact name or GUID in the resolved environment. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamGetCliCommand : ProfiledCliCommand +public class TeamGetCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamGetCliCommand)); @@ -27,11 +24,12 @@ public class TeamGetCliCommand : ProfiledCliCommand private async Task ExecuteGetAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team get", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); try { - var team = await service.GetAsync(Profile, Team, CancellationToken.None).ConfigureAwait(false); + var team = await service.GetAsync(Profile, Team, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); if (team is null) { Logger.LogError("Dataverse team '{Team}' was not found.", Team); diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamListCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamListCliCommand.cs similarity index 54% rename from src/TALXIS.CLI.Features.Environment/Team/TeamListCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamListCliCommand.cs index 5e50fa24..94bd3b48 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamListCliCommand.cs @@ -3,28 +3,25 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Lists Dataverse teams in the current environment. -/// Usage: txc environment team list -/// [CliReadOnly] [CliCommand( Name = "list", - Description = "List Dataverse teams in the current environment." + Description = "List Dataverse teams in the resolved environment. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamListCliCommand : ProfiledCliCommand +public class TeamListCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamListCliCommand)); protected override async Task ExecuteAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team list", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); - var rows = await service.ListAsync(Profile, CancellationToken.None).ConfigureAwait(false); - + var rows = await service.ListAsync(Profile, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); OutputFormatter.WriteList(rows, TeamCommandSupport.WriteTeamList); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamMemberAddCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamMemberAddCliCommand.cs similarity index 69% rename from src/TALXIS.CLI.Features.Environment/Team/TeamMemberAddCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamMemberAddCliCommand.cs index 45920f05..eb409307 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamMemberAddCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamMemberAddCliCommand.cs @@ -3,20 +3,17 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Adds a direct member to an owner or access Dataverse team. -/// Usage: txc environment team member add --team <name-or-guid> --user <upn-or-guid> -/// [CliIdempotent] [CliCommand( Name = "add", - Description = "Add a direct member to an owner or access team. AAD-backed team membership is managed in Entra ID and is rejected by this command." + Description = "Add a direct member to an owner or access Dataverse team in the resolved environment. AAD-backed team membership is managed in Entra ID and is rejected by this command. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamMemberAddCliCommand : ProfiledCliCommand +public class TeamMemberAddCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamMemberAddCliCommand)); @@ -30,11 +27,12 @@ public class TeamMemberAddCliCommand : ProfiledCliCommand private async Task ExecuteAddMemberAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team member add", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); try { - await service.AddMemberAsync(Profile, Team, User, CancellationToken.None).ConfigureAwait(false); + await service.AddMemberAsync(Profile, Team, User, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); OutputFormatter.WriteResult("succeeded", $"Added user '{User}' to team '{Team}'."); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamMemberListCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamMemberListCliCommand.cs similarity index 69% rename from src/TALXIS.CLI.Features.Environment/Team/TeamMemberListCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamMemberListCliCommand.cs index 479bf143..c264f41a 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamMemberListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamMemberListCliCommand.cs @@ -3,20 +3,17 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Lists direct members of a Dataverse team. -/// Usage: txc environment team member list --team <name-or-guid> -/// [CliReadOnly] [CliCommand( Name = "list", - Description = "List direct members of a Dataverse team. For AAD-backed teams, the list reflects current team membership but add/remove is managed in Entra ID." + Description = "List direct Dataverse team members in the resolved environment. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamMemberListCliCommand : ProfiledCliCommand +public class TeamMemberListCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamMemberListCliCommand)); @@ -27,11 +24,12 @@ public class TeamMemberListCliCommand : ProfiledCliCommand private async Task ExecuteListMembersAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team member list", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); try { - var rows = await service.ListMembersAsync(Profile, Team, CancellationToken.None).ConfigureAwait(false); + var rows = await service.ListMembersAsync(Profile, Team, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); OutputFormatter.WriteList(rows, TeamCommandSupport.WriteMemberList); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamMemberRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamMemberRemoveCliCommand.cs similarity index 68% rename from src/TALXIS.CLI.Features.Environment/Team/TeamMemberRemoveCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamMemberRemoveCliCommand.cs index ad48be27..c9ed9cab 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamMemberRemoveCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamMemberRemoveCliCommand.cs @@ -4,20 +4,17 @@ using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Removes a direct member from an owner or access Dataverse team. -/// Usage: txc environment team member remove --team <name-or-guid> --user <upn-or-guid> --yes -/// -[CliDestructive("Removes the direct member from the Dataverse team.")] +[CliDestructive("Removing a team member can immediately reduce what the user can do through that team.")] [CliCommand( Name = "remove", - Description = "Remove a direct member from an owner or access team. AAD-backed team membership is managed in Entra ID and is rejected by this command. This is destructive." + Description = "Remove a direct Dataverse team member in the resolved environment. AAD-backed team membership is managed in Entra ID and is rejected by this command. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamMemberRemoveCliCommand : ProfiledCliCommand, IDestructiveCommand +public class TeamMemberRemoveCliCommand : SecurityScopedCliCommand, IDestructiveCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamMemberRemoveCliCommand)); @@ -34,11 +31,12 @@ public class TeamMemberRemoveCliCommand : ProfiledCliCommand, IDestructiveComman private async Task ExecuteRemoveMemberAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team member remove", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); try { - await service.RemoveMemberAsync(Profile, Team, User, CancellationToken.None).ConfigureAwait(false); + await service.RemoveMemberAsync(Profile, Team, User, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); OutputFormatter.WriteResult("succeeded", $"Removed user '{User}' from team '{Team}'."); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamRoleAddCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamRoleAddCliCommand.cs similarity index 67% rename from src/TALXIS.CLI.Features.Environment/Team/TeamRoleAddCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamRoleAddCliCommand.cs index d6202041..e504ca31 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamRoleAddCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamRoleAddCliCommand.cs @@ -3,20 +3,17 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Assigns a security role to a Dataverse team. -/// Usage: txc environment team role add --team <name-or-guid> --role <name-or-guid> -/// [CliIdempotent] [CliCommand( Name = "add", - Description = "Assign a security role to a Dataverse team. Not supported for access teams (Dataverse restriction: access teams are used only for record sharing, not role-based security) — valid for owner, aad-security-group, and aad-office-group teams." + Description = "Assign a Dataverse security role to a team in the resolved environment. Not supported for access teams because Dataverse uses them only for record sharing. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamRoleAddCliCommand : ProfiledCliCommand +public class TeamRoleAddCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamRoleAddCliCommand)); @@ -30,18 +27,19 @@ public class TeamRoleAddCliCommand : ProfiledCliCommand private async Task ExecuteAddRoleAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team role add", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); try { - var existingRoles = await service.ListRolesAsync(Profile, Team, CancellationToken.None).ConfigureAwait(false); - if (existingRoles.Any(r => EnvironmentPrincipalCommandSupport.IsRoleMatch(r, Role))) + var existingRoles = await service.ListRolesAsync(Profile, Team, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + if (existingRoles.Any(r => SecurityPrincipalCommandSupport.IsRoleMatch(r, Role))) { OutputFormatter.WriteResult("unchanged", $"Role '{Role}' is already assigned to team '{Team}'."); return ExitSuccess; } - await service.AddRoleAsync(Profile, Team, Role, CancellationToken.None).ConfigureAwait(false); + await service.AddRoleAsync(Profile, Team, Role, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); OutputFormatter.WriteResult("succeeded", $"Added role '{Role}' to team '{Team}'."); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamRoleListCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamRoleListCliCommand.cs similarity index 69% rename from src/TALXIS.CLI.Features.Environment/Team/TeamRoleListCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamRoleListCliCommand.cs index 5a75301b..2f7d2ca6 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamRoleListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamRoleListCliCommand.cs @@ -3,20 +3,17 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Lists security roles assigned to a Dataverse team. -/// Usage: txc environment team role list --team <name-or-guid> -/// [CliReadOnly] [CliCommand( Name = "list", - Description = "List security roles assigned to a Dataverse team. Valid for all team types." + Description = "List the Dataverse security roles assigned to a team in the resolved environment. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamRoleListCliCommand : ProfiledCliCommand +public class TeamRoleListCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamRoleListCliCommand)); @@ -27,11 +24,12 @@ public class TeamRoleListCliCommand : ProfiledCliCommand private async Task ExecuteListRolesAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team role list", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); try { - var rows = await service.ListRolesAsync(Profile, Team, CancellationToken.None).ConfigureAwait(false); + var rows = await service.ListRolesAsync(Profile, Team, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); OutputFormatter.WriteList(rows, TeamCommandSupport.WriteRoleList); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/Team/TeamRoleRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Security/Team/TeamRoleRemoveCliCommand.cs similarity index 69% rename from src/TALXIS.CLI.Features.Environment/Team/TeamRoleRemoveCliCommand.cs rename to src/TALXIS.CLI.Features.Security/Team/TeamRoleRemoveCliCommand.cs index b941c1ec..78ed49d2 100644 --- a/src/TALXIS.CLI.Features.Environment/Team/TeamRoleRemoveCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/Team/TeamRoleRemoveCliCommand.cs @@ -4,20 +4,17 @@ using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Team; +namespace TALXIS.CLI.Features.Security.Team; -/// -/// Removes a security role from a Dataverse team. -/// Usage: txc environment team role remove --team <name-or-guid> --role <name-or-guid> --yes -/// -[CliDestructive("Removes the security role assignment from the Dataverse team.")] +[CliDestructive("Removing a team security role can immediately reduce what the team can do in the environment.")] [CliCommand( Name = "remove", - Description = "Remove a security role from a Dataverse team. Not supported for access teams (Dataverse restriction: access teams are used only for record sharing, not role-based security) — valid for owner, aad-security-group, and aad-office-group teams. This is destructive." + Description = "Remove a Dataverse security role from a team in the resolved environment. Pass --environment explicitly or use a profile already connected to an environment." )] -public class TeamRoleRemoveCliCommand : ProfiledCliCommand, IDestructiveCommand +public class TeamRoleRemoveCliCommand : SecurityScopedCliCommand, IDestructiveCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TeamRoleRemoveCliCommand)); @@ -34,11 +31,12 @@ public class TeamRoleRemoveCliCommand : ProfiledCliCommand, IDestructiveCommand private async Task ExecuteRemoveRoleAsync() { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security team role remove", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); try { - await service.RemoveRoleAsync(Profile, Team, Role, CancellationToken.None).ConfigureAwait(false); + await service.RemoveRoleAsync(Profile, Team, Role, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); OutputFormatter.WriteResult("succeeded", $"Removed role '{Role}' from team '{Team}'."); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Security/User/SecurityPrincipalCommandSupport.cs b/src/TALXIS.CLI.Features.Security/User/SecurityPrincipalCommandSupport.cs new file mode 100644 index 00000000..071cb340 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/User/SecurityPrincipalCommandSupport.cs @@ -0,0 +1,311 @@ +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control; + +namespace TALXIS.CLI.Features.Security; + +internal static class SecurityPrincipalCommandSupport +{ + internal static Task ResolveContextAsync(string? profile, CancellationToken ct) + { + var configurationResolver = TxcServices.Get(); + return configurationResolver.ResolveAsync(profile, ct); + } + + internal static async Task ResolveScopeAsync( + string? profile, + Guid? environmentId, + CancellationToken ct) + { + var tenantContext = await ResolveContextAsync(profile, ct).ConfigureAwait(false); + + if (environmentId.HasValue) + { + var environment = await ResolveEnvironmentByIdAsync(tenantContext, environmentId.Value, ct).ConfigureAwait(false); + return new SecurityScopeContext( + tenantContext, + CreateEnvironmentContext(tenantContext, environment), + environment.EnvironmentId, + environment.DisplayName, + environment.EnvironmentUrl, + true); + } + + if (HasEnvironmentConnection(tenantContext.Connection)) + { + var environment = await TryResolveEnvironmentAsync(tenantContext, ct).ConfigureAwait(false); + return new SecurityScopeContext( + tenantContext, + tenantContext, + environment?.EnvironmentId ?? tenantContext.Connection.EnvironmentId, + environment?.DisplayName ?? tenantContext.Connection.DisplayName, + environment?.EnvironmentUrl ?? ParseEnvironmentUrl(tenantContext.Connection.EnvironmentUrl), + false); + } + + return new SecurityScopeContext(tenantContext, null, null, null, null, false); + } + + internal static async Task ResolveRequiredEnvironmentScopeAsync( + string? profile, + Guid? environmentId, + string commandPath, + CancellationToken ct) + { + var scope = await ResolveScopeAsync(profile, environmentId, ct).ConfigureAwait(false); + if (scope.EnvironmentContext is not null) + return scope; + + throw new ConfigurationResolutionException( + $"'{commandPath}' requires a Dataverse environment. Pass --environment or use a profile connected to an environment."); + } + + internal static bool TryHandleValidationException(ILogger logger, Exception ex, out int exitCode) + { + if (ex is TenantPrincipalAmbiguousException ambiguousPrincipal) + { + logger.LogError("{Error}", ambiguousPrincipal.Message); + foreach (var candidate in ambiguousPrincipal.Candidates) + logger.LogError("Candidate: {Candidate}", candidate); + + exitCode = 2; + return true; + } + + if (ex is TenantRoleAmbiguousException ambiguousRole) + { + logger.LogError("{Error}", ambiguousRole.Message); + foreach (var candidate in ambiguousRole.CandidateNames) + logger.LogError("Candidate: {Candidate}", candidate); + + exitCode = 2; + return true; + } + + if (ex is DataverseAmbiguousMatchException ambiguousDataverse) + { + logger.LogError("Multiple {EntityDisplayName} records matched '{Identifier}'.", ambiguousDataverse.EntityDisplayName, ambiguousDataverse.Identifier); + foreach (var candidate in ambiguousDataverse.Candidates) + { + if (string.IsNullOrWhiteSpace(candidate.Description)) + { + logger.LogError(" - {Name} ({Id})", candidate.Name, candidate.Id); + } + else + { + logger.LogError(" - {Name} [{Description}] ({Id})", candidate.Name, candidate.Description, candidate.Id); + } + } + + exitCode = 2; + return true; + } + + if (ex is ArgumentException or InvalidOperationException) + { + logger.LogError("{Error}", ex.Message); + exitCode = 2; + return true; + } + + exitCode = 0; + return false; + } + + internal static void WriteRoleTable(IReadOnlyList assignments) + { +#pragma warning disable TXC003 + if (assignments.Count == 0) + { + OutputWriter.WriteLine("No tenant roles assigned."); + return; + } + + int roleNameWidth = Math.Clamp(assignments.Max(a => a.RoleName.Length), 9, 36); + int roleIdWidth = Math.Clamp(assignments.Max(a => a.RoleIdentifier.Length), 7, 36); + int scopeWidth = Math.Clamp(assignments.Max(a => a.Scope.Length), 5, 48); + + string header = + $"{"Role Name".PadRight(roleNameWidth)} | " + + $"{"Role ID".PadRight(roleIdWidth)} | " + + "Scope"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var assignment in assignments) + { + OutputWriter.WriteLine( + $"{Truncate(assignment.RoleName, roleNameWidth).PadRight(roleNameWidth)} | " + + $"{Truncate(assignment.RoleIdentifier, roleIdWidth).PadRight(roleIdWidth)} | " + + $"{Truncate(assignment.Scope, scopeWidth)}"); + } +#pragma warning restore TXC003 + } + + internal static void WriteCombinedRoleSections( + IReadOnlyList tenantAssignments, + IReadOnlyList environmentAssignments, + SecurityScopeContext scope, + Action> environmentRenderer) + { +#pragma warning disable TXC003 + OutputWriter.WriteLine("Tenant admin roles"); + OutputWriter.WriteLine("------------------"); + WriteRoleTable(tenantAssignments); + OutputWriter.WriteLine(); + OutputWriter.WriteLine($"Environment security roles ({FormatEnvironmentLabel(scope)})"); + OutputWriter.WriteLine(new string('-', $"Environment security roles ({FormatEnvironmentLabel(scope)})".Length)); + environmentRenderer(environmentAssignments); +#pragma warning restore TXC003 + } + + internal static void WriteMutationResult(T payload, Action textRenderer) + => OutputFormatter.WriteData(payload, _ => textRenderer()); + + internal static bool TryResolveStateFilter( + bool enabled, + bool disabled, + bool all, + ILogger logger, + out DataverseSecurityPrincipalStateFilter filter) + { + var selected = (enabled ? 1 : 0) + (disabled ? 1 : 0) + (all ? 1 : 0); + if (selected > 1) + { + logger.LogError("Specify at most one of --enabled, --disabled, or --all."); + filter = default; + return false; + } + + filter = disabled + ? DataverseSecurityPrincipalStateFilter.Disabled + : all + ? DataverseSecurityPrincipalStateFilter.All + : DataverseSecurityPrincipalStateFilter.Enabled; + return true; + } + + internal static bool TryParseRoleIdentifiers( + string? csv, + ILogger logger, + out IReadOnlyList roles) + { + if (string.IsNullOrWhiteSpace(csv)) + { + roles = Array.Empty(); + return true; + } + + var parsed = csv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (parsed.Length == 0) + { + logger.LogError("--role must contain at least one role name or GUID when specified."); + roles = Array.Empty(); + return false; + } + + roles = parsed; + return true; + } + + internal static bool IsRoleMatch(DataverseRoleRecord role, string roleNameOrGuid) + => string.Equals(role.Id.ToString(), roleNameOrGuid, StringComparison.OrdinalIgnoreCase) + || string.Equals(role.Name, roleNameOrGuid, StringComparison.OrdinalIgnoreCase); + + internal static string Truncate(string value, int maxWidth) + => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; + + internal static string FormatEnvironmentLabel(SecurityScopeContext scope) + => scope.EnvironmentId?.ToString() + ?? scope.EnvironmentUrl?.AbsoluteUri + ?? scope.EnvironmentDisplayName + ?? "active environment"; + + private static bool HasEnvironmentConnection(Connection connection) + => connection.EnvironmentId.HasValue || !string.IsNullOrWhiteSpace(connection.EnvironmentUrl); + + private static async Task TryResolveEnvironmentAsync( + ResolvedProfileContext context, + CancellationToken ct) + { + var catalog = TxcServices.Get(); + var environments = await catalog.ListAsync(context.Connection, context.Credential, ct).ConfigureAwait(false); + + if (context.Connection.EnvironmentId.HasValue) + return environments.SingleOrDefault(candidate => candidate.EnvironmentId == context.Connection.EnvironmentId.Value); + + var environmentUrl = ParseEnvironmentUrl(context.Connection.EnvironmentUrl); + return environmentUrl is null + ? null + : environments.SingleOrDefault(candidate => UrlEquals(candidate.EnvironmentUrl, environmentUrl)); + } + + private static async Task ResolveEnvironmentByIdAsync( + ResolvedProfileContext context, + Guid environmentId, + CancellationToken ct) + { + var catalog = TxcServices.Get(); + return (await catalog.ListAsync(context.Connection, context.Credential, ct).ConfigureAwait(false)) + .SingleOrDefault(candidate => candidate.EnvironmentId == environmentId) + ?? throw new InvalidOperationException( + $"Power Platform environment '{environmentId}' was not found or is not accessible with the resolved profile."); + } + + private static ResolvedProfileContext CreateEnvironmentContext( + ResolvedProfileContext source, + PowerPlatformEnvironmentSummary environment) + => new( + source.Profile, + new Connection + { + Id = source.Connection.Id, + Provider = source.Connection.Provider, + Description = source.Connection.Description, + EnvironmentUrl = environment.EnvironmentUrl.AbsoluteUri, + OrganizationId = environment.OrganizationId?.ToString(), + EnvironmentId = environment.EnvironmentId, + Cloud = source.Connection.Cloud, + TenantId = source.Connection.TenantId, + DisplayName = environment.DisplayName, + EnvironmentType = environment.EnvironmentType, + CreatedAt = source.Connection.CreatedAt, + UpdatedAt = source.Connection.UpdatedAt, + ExtraFields = source.Connection.ExtraFields, + }, + source.Credential, + source.Source); + + private static Uri? ParseEnvironmentUrl(string? environmentUrl) + => Uri.TryCreate(environmentUrl, UriKind.Absolute, out var uri) + ? NormalizeEnvironmentUrl(uri) + : null; + + private static bool UrlEquals(Uri left, Uri right) + => NormalizeEnvironmentUrl(left).AbsoluteUri.Equals( + NormalizeEnvironmentUrl(right).AbsoluteUri, + StringComparison.OrdinalIgnoreCase); + + private static Uri NormalizeEnvironmentUrl(Uri uri) + => new(uri.GetLeftPart(UriPartial.Path).TrimEnd('/') + "/"); +} + +internal sealed record SecurityScopeContext( + ResolvedProfileContext TenantContext, + ResolvedProfileContext? EnvironmentContext, + Guid? EnvironmentId, + string? EnvironmentDisplayName, + Uri? EnvironmentUrl, + bool ExplicitEnvironment) +{ + public bool HasEnvironment => EnvironmentContext is not null; +} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserAddCliCommand.cs b/src/TALXIS.CLI.Features.Security/User/UserAddCliCommand.cs similarity index 69% rename from src/TALXIS.CLI.Features.Environment/User/UserAddCliCommand.cs rename to src/TALXIS.CLI.Features.Security/User/UserAddCliCommand.cs index d3a5f8ec..c025184d 100644 --- a/src/TALXIS.CLI.Features.Environment/User/UserAddCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/User/UserAddCliCommand.cs @@ -4,33 +4,33 @@ using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Core.Platforms.PowerPlatform; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.User; +namespace TALXIS.CLI.Features.Security.User; /// -/// Provisions an Entra user into the environment so security roles can be -/// assigned immediately, without waiting for background JIT sync. -/// Usage: txc environment user add --user <upn-or-object-id> [--role <csv>] +/// Provisions an Entra user into a Dataverse environment so security roles can be assigned immediately. +/// Usage: txc security user add --user <upn-or-object-id> [--role <csv>] [--environment <id>] /// [CliIdempotent] [CliCommand( Name = "add", - Description = "Grant a brand-new Entra user access to this environment. Use this before role add/self-elevate when the user has never signed in here and has no environment user record yet — a regular Dataverse user record cannot be created any other way, since it is otherwise only created by background sync the first time the user signs in. Use --role with one comma-separated value to assign initial roles in the same step. Safe to run again for a user who already has access. To grant yourself admin access when you don't yet have any access to this environment, use 'environment user self-elevate' instead." + Description = "Grant a brand-new Entra user access to a Dataverse environment so role assignment commands can run immediately. This command requires --environment or an active environment connection because there is no tenant-wide equivalent. Use --role with a comma-separated list to assign initial Dataverse security roles in the same step." )] -public class UserAddCliCommand : ProfiledCliCommand +public class UserAddCliCommand : SecurityScopedCliCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserAddCliCommand)); [CliOption(Name = "--user", Description = "User principal name or Entra object ID.", Required = true)] public string User { get; set; } = string.Empty; - [CliOption(Name = "--role", Description = "Comma-separated role names or GUIDs, for example \"Basic User,Sales Manager\".", Required = false)] + [CliOption(Name = "--role", Description = "Comma-separated Dataverse role names or GUIDs, for example \"Basic User,Sales Manager\".", Required = false)] public string? Role { get; set; } protected override Task ExecuteAsync() { - if (!UserCliCommandSupport.TryParseRoleIdentifiers(Role, Logger, out var requestedRoles)) + if (!SecurityPrincipalCommandSupport.TryParseRoleIdentifiers(Role, Logger, out var requestedRoles)) return Task.FromResult(ExitValidationError); return ExecuteAddAsync(requestedRoles); @@ -38,21 +38,17 @@ protected override Task ExecuteAsync() private async Task ExecuteAddAsync(IReadOnlyList requestedRoles) { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security user add", CancellationToken.None).ConfigureAwait(false); + try { var provisioning = TxcServices.Get(); - var provisioned = await provisioning.ProvisionUserAsync(Profile, User, CancellationToken.None).ConfigureAwait(false); - - // Roles are assigned by UPN/object-id lookup against the Dataverse - // systemuser this call just ensured exists — prefer the resolved - // UPN (matches Dataverse's domainname lookup) and fall back to the - // original --user value (already accepted as GUID or UPN) if Graph - // didn't return one. + var provisioned = await provisioning.ProvisionUserAsync(Profile, User, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); var userIdentifier = provisioned.UserPrincipalName ?? User; if (requestedRoles.Count == 0) { - WriteAddResult(provisioned, Array.Empty(), Array.Empty()); + WriteAddResult(provisioned, scope.EnvironmentId, Array.Empty(), Array.Empty()); return ExitSuccess; } @@ -61,9 +57,9 @@ private async Task ExecuteAddAsync(IReadOnlyList requestedRoles) var failures = new List(); foreach (var role in requestedRoles) - await TryAssignRoleAsync(service, userIdentifier, role, assignedRoles, failures).ConfigureAwait(false); + await TryAssignRoleAsync(service, scope.EnvironmentId, userIdentifier, role, assignedRoles, failures).ConfigureAwait(false); - WriteAddResult(provisioned, assignedRoles, failures); + WriteAddResult(provisioned, scope.EnvironmentId, assignedRoles, failures); if (failures.Count == 0) return ExitSuccess; @@ -75,7 +71,7 @@ private async Task ExecuteAddAsync(IReadOnlyList requestedRoles) ? ExitValidationError : ExitError; } - catch (Exception ex) when (UserCliCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) { return exitCode; } @@ -83,6 +79,7 @@ private async Task ExecuteAddAsync(IReadOnlyList requestedRoles) private async Task TryAssignRoleAsync( IDataverseUserService service, + Guid? environmentId, string userIdentifier, string role, ICollection assignedRoles, @@ -90,7 +87,7 @@ private async Task TryAssignRoleAsync( { try { - await service.AddRoleAsync(Profile, userIdentifier, role, CancellationToken.None).ConfigureAwait(false); + await service.AddRoleAsync(Profile, userIdentifier, role, CancellationToken.None, environmentId).ConfigureAwait(false); assignedRoles.Add(role); } catch (Exception ex) when (ex is DataverseAmbiguousMatchException or ArgumentException or InvalidOperationException) @@ -105,12 +102,14 @@ private async Task TryAssignRoleAsync( private static void WriteAddResult( EnvironmentUserProvisionResult provisioned, + Guid? environmentId, IReadOnlyList assignedRoles, IReadOnlyList failures) { var payload = new { status = failures.Count == 0 ? "added" : "partial", + environmentId, aadObjectId = provisioned.AadObjectId, userPrincipalName = provisioned.UserPrincipalName, displayName = provisioned.DisplayName, @@ -128,6 +127,7 @@ private static void WriteAddResult( OutputWriter.WriteLine(failures.Count == 0 ? "User granted access to this environment." : "User granted access to this environment, but one or more role assignments failed."); + OutputWriter.WriteLine($"Environment ID: {environmentId?.ToString() ?? "-"}"); OutputWriter.WriteLine($"Entra Object ID: {provisioned.AadObjectId}"); OutputWriter.WriteLine($"UPN: {provisioned.UserPrincipalName ?? "-"}"); OutputWriter.WriteLine($"Display Name: {provisioned.DisplayName ?? "-"}"); diff --git a/src/TALXIS.CLI.Features.Security/User/UserCliCommand.cs b/src/TALXIS.CLI.Features.Security/User/UserCliCommand.cs new file mode 100644 index 00000000..96391978 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/User/UserCliCommand.cs @@ -0,0 +1,52 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Security.User; + +/// +/// Parent command for tenant-wide Entra user discovery and Dataverse environment-user access management. +/// Usage: txc security user [list|get|add|update|role|self-elevate] +/// +[CliCommand( + Name = "user", + Description = "Discover Entra users tenant-wide, or manage Dataverse environment users when --environment is provided or resolved from the active connection.", + Children = new[] + { + typeof(UserListCliCommand), + typeof(UserGetCliCommand), + typeof(UserAddCliCommand), + typeof(UserUpdateCliCommand), + typeof(UserRoleCliCommand), + typeof(UserSelfElevateCliCommand) + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class UserCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} + +/// +/// Sub-resource for tenant-wide and Dataverse security-role assignments on a user. +/// Usage: txc security user role [list|add|remove] +/// +[CliCommand( + Name = "role", + Description = "List, add, or remove tenant admin roles and Dataverse security roles for a user. With an environment scope, role list shows tenant admin roles and environment security roles in separate sections.", + Children = new[] + { + typeof(UserRoleListCliCommand), + typeof(UserRoleAddCliCommand), + typeof(UserRoleRemoveCliCommand) + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class UserRoleCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Security/User/UserCommandSupport.cs b/src/TALXIS.CLI.Features.Security/User/UserCommandSupport.cs new file mode 100644 index 00000000..c429ee53 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/User/UserCommandSupport.cs @@ -0,0 +1,314 @@ +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Platform.PowerPlatform.Control; +using TALXIS.CLI.Platform.PowerPlatform.Control.Graph; +using Microsoft.Extensions.Logging; + +namespace TALXIS.CLI.Features.Security.User; + +internal static class UserCommandSupport +{ + public static async Task> ListUsersAsync( + string? profile, + string? filter, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var graph = TxcServices.Get(); + return await graph.ListUsersAsync( + context.Connection, + context.Credential, + BuildListFilter(filter), + top: 100, + ct).ConfigureAwait(false); + } + + public static async Task GetUserAsync( + string? profile, + string user, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var graph = TxcServices.Get(); + var matches = await graph.ListUsersAsync( + context.Connection, + context.Credential, + BuildGetFilter(user), + top: 25, + ct).ConfigureAwait(false); + + var exactMatches = matches + .Where(candidate => MatchesUser(candidate, user.Trim())) + .ToList(); + + if (exactMatches.Count == 0) + throw new TenantPrincipalNotFoundException(PowerPlatformPrincipalType.User, user); + + if (exactMatches.Count > 1) + { + throw new TenantPrincipalAmbiguousException( + PowerPlatformPrincipalType.User, + user, + exactMatches.Select(FormatCandidate)); + } + + return exactMatches[0]; + } + + public static async Task> ListTenantRolesAsync( + string? profile, + string user, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); + return await resolver.ListAssignmentsAsync( + context.Connection, + context.Credential, + PowerPlatformPrincipalType.User, + user, + ct).ConfigureAwait(false); + } + + public static async Task AddTenantRoleAsync( + string? profile, + string user, + string role, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); + await resolver.AddAssignmentAsync( + context.Connection, + context.Credential, + PowerPlatformPrincipalType.User, + user, + role, + ct).ConfigureAwait(false); + } + + public static async Task RemoveTenantRoleAsync( + string? profile, + string user, + string role, + CancellationToken ct) + { + var context = await SecurityPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); + var resolver = TxcServices.Get(); + await resolver.RemoveAssignmentAsync( + context.Connection, + context.Credential, + PowerPlatformPrincipalType.User, + user, + role, + ct).ConfigureAwait(false); + } + + public static async Task ResolveEnvironmentUserAsync( + IDataverseUserService service, + string? profileName, + string userIdOrUpn, + Guid? environmentId, + ILogger logger, + CancellationToken ct) + { + try + { + var user = await service.GetAsync(profileName, userIdOrUpn, ct, environmentId).ConfigureAwait(false); + if (user is null) + logger.LogError("Dataverse user '{User}' was not found.", userIdOrUpn); + + return user; + } + catch (DataverseAmbiguousMatchException ex) + { + LogDataverseAmbiguousMatch(logger, ex); + return null; + } + } + + public static async Task ResolveEnvironmentRoleAsync( + IDataverseRoleService service, + string? profileName, + string roleNameOrGuid, + Guid? environmentId, + ILogger logger, + CancellationToken ct) + { + try + { + var role = await service.GetAsync(profileName, roleNameOrGuid, ct, environmentId).ConfigureAwait(false); + if (role is null) + logger.LogError("Dataverse role '{Role}' was not found.", roleNameOrGuid); + + return role; + } + catch (DataverseAmbiguousMatchException ex) + { + LogDataverseAmbiguousMatch(logger, ex); + return null; + } + } + + public static void LogDataverseAmbiguousMatch(ILogger logger, DataverseAmbiguousMatchException ex) + { + logger.LogError("Multiple {EntityDisplayName} records matched '{Identifier}'.", ex.EntityDisplayName, ex.Identifier); + foreach (var candidate in ex.Candidates) + { + if (string.IsNullOrWhiteSpace(candidate.Description)) + { + logger.LogError(" - {Name} ({Id})", candidate.Name, candidate.Id); + } + else + { + logger.LogError(" - {Name} [{Description}] ({Id})", candidate.Name, candidate.Description, candidate.Id); + } + } + } + + public static string FormatUserLabel(DataverseUserRecord user) + => user.UserPrincipalName + ?? user.PrimaryEmailAddress + ?? user.FullName + ?? user.Id.ToString(); + + public static void PrintEnvironmentUsersTable(IReadOnlyList rows) + { +#pragma warning disable TXC003 + if (rows.Count == 0) + { + OutputWriter.WriteLine("No environment users found."); + return; + } + + int nameWidth = Math.Clamp(rows.Max(r => (r.FullName ?? string.Empty).Length), 4, 28); + int upnWidth = Math.Clamp(rows.Max(r => (r.UserPrincipalName ?? string.Empty).Length), 3, 36); + int emailWidth = Math.Clamp(rows.Max(r => (r.PrimaryEmailAddress ?? string.Empty).Length), 5, 36); + int stateWidth = 8; + int buWidth = Math.Clamp(rows.Max(r => (r.BusinessUnitName ?? string.Empty).Length), 13, 28); + + string header = + $"{"Name".PadRight(nameWidth)} | " + + $"{"UPN".PadRight(upnWidth)} | " + + $"{"Email".PadRight(emailWidth)} | " + + $"{"State".PadRight(stateWidth)} | " + + $"{"Business Unit".PadRight(buWidth)} | User ID"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var row in rows) + { + OutputWriter.WriteLine( + $"{SecurityPrincipalCommandSupport.Truncate(row.FullName ?? string.Empty, nameWidth).PadRight(nameWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.UserPrincipalName ?? string.Empty, upnWidth).PadRight(upnWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.PrimaryEmailAddress ?? string.Empty, emailWidth).PadRight(emailWidth)} | " + + $"{(row.IsDisabled ? "disabled" : "enabled").PadRight(stateWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? string.Empty, buWidth).PadRight(buWidth)} | {row.Id}"); + } +#pragma warning restore TXC003 + } + + public static void PrintEnvironmentUserDetail(DataverseUserRecord user) + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"User ID: {user.Id}"); + OutputWriter.WriteLine($"Name: {user.FullName ?? "-"}"); + OutputWriter.WriteLine($"UPN: {user.UserPrincipalName ?? "-"}"); + OutputWriter.WriteLine($"Email: {user.PrimaryEmailAddress ?? "-"}"); + OutputWriter.WriteLine($"Entra Object ID: {user.AzureActiveDirectoryObjectId?.ToString() ?? "-"}"); + OutputWriter.WriteLine($"State: {(user.IsDisabled ? "disabled" : "enabled")}"); + OutputWriter.WriteLine($"Business Unit: {user.BusinessUnitName ?? "-"}"); +#pragma warning restore TXC003 + } + + public static void PrintEnvironmentRolesTable(IReadOnlyList rows) + { +#pragma warning disable TXC003 + if (rows.Count == 0) + { + OutputWriter.WriteLine("No security roles assigned."); + return; + } + + int nameWidth = Math.Clamp(rows.Max(r => r.Name.Length), 4, 48); + int buWidth = Math.Clamp(rows.Max(r => (r.BusinessUnitName ?? string.Empty).Length), 13, 28); + string header = $"{"Role".PadRight(nameWidth)} | {"Business Unit".PadRight(buWidth)} | Role ID"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var row in rows.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)) + { + OutputWriter.WriteLine( + $"{SecurityPrincipalCommandSupport.Truncate(row.Name, nameWidth).PadRight(nameWidth)} | " + + $"{SecurityPrincipalCommandSupport.Truncate(row.BusinessUnitName ?? string.Empty, buWidth).PadRight(buWidth)} | {row.Id}"); + } +#pragma warning restore TXC003 + } + + internal static void PrintUserList(IReadOnlyList users) + { +#pragma warning disable TXC003 + if (users.Count == 0) + { + OutputWriter.WriteLine("No users found."); + return; + } + + int upnWidth = Math.Clamp(users.Max(u => (u.UserPrincipalName ?? string.Empty).Length), 3, 48); + int nameWidth = Math.Clamp(users.Max(u => (u.DisplayName ?? string.Empty).Length), 12, 36); + + string header = + $"{"UPN".PadRight(upnWidth)} | " + + $"{"Display Name".PadRight(nameWidth)} | " + + "Object ID"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var user in users) + { + OutputWriter.WriteLine( + $"{Truncate(user.UserPrincipalName ?? string.Empty, upnWidth).PadRight(upnWidth)} | " + + $"{Truncate(user.DisplayName ?? string.Empty, nameWidth).PadRight(nameWidth)} | " + + $"{user.Id}"); + } +#pragma warning restore TXC003 + } + + internal static void PrintUserDetail(GraphUser user) + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"UPN: {user.UserPrincipalName ?? "-"}"); + OutputWriter.WriteLine($"Display Name: {user.DisplayName ?? "-"}"); + OutputWriter.WriteLine($"Object ID: {user.Id}"); +#pragma warning restore TXC003 + } + + private static string? BuildListFilter(string? filter) + { + if (string.IsNullOrWhiteSpace(filter)) + return null; + + var escaped = GraphODataFilterSupport.EscapeODataString(filter.Trim()); + return $"startswith(userPrincipalName,'{escaped}') or startswith(displayName,'{escaped}')"; + } + + private static string BuildGetFilter(string user) + { + ArgumentException.ThrowIfNullOrWhiteSpace(user); + return GraphODataFilterSupport.BuildIdentifierFilter(user, ["id"], ["userPrincipalName"]); + } + + private static bool MatchesUser(GraphUser user, string input) + => user.Id.ToString().Equals(input, StringComparison.OrdinalIgnoreCase) + || string.Equals(user.UserPrincipalName, input, StringComparison.OrdinalIgnoreCase); + + private static string FormatCandidate(GraphUser user) + => string.IsNullOrWhiteSpace(user.UserPrincipalName) + ? $"{user.DisplayName ?? "(no display name)"} ({user.Id})" + : $"{user.UserPrincipalName} ({user.Id})"; + + private static string Truncate(string value, int maxWidth) + => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; +} diff --git a/src/TALXIS.CLI.Features.Security/User/UserGetCliCommand.cs b/src/TALXIS.CLI.Features.Security/User/UserGetCliCommand.cs new file mode 100644 index 00000000..bfa58790 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/User/UserGetCliCommand.cs @@ -0,0 +1,51 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.User; + +/// +/// Gets one Entra user tenant-wide, or one Dataverse environment user when an environment scope is resolved. +/// Usage: txc security user get --user <upn-or-object-id> [--environment <id>] +/// +[CliReadOnly] +[CliCommand( + Name = "get", + Description = "Get one Entra user by user principal name or object ID when no environment is resolved. When --environment is provided or the active connection already targets an environment, get the Dataverse environment user by user principal name or system user GUID instead." +)] +public class UserGetCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserGetCliCommand)); + + [CliOption(Name = "--user", Description = "User principal name or Entra object ID. With an environment scope, a Dataverse system user GUID is also accepted.", Required = true)] + public string User { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + if (scope.HasEnvironment) + { + var service = TxcServices.Get(); + var user = await UserCommandSupport.ResolveEnvironmentUserAsync( + service, + Profile, + User, + scope.EnvironmentId, + Logger, + CancellationToken.None).ConfigureAwait(false); + if (user is null) + return ExitValidationError; + + OutputFormatter.WriteData(user, UserCommandSupport.PrintEnvironmentUserDetail); + return ExitSuccess; + } + + var tenantUser = await UserCommandSupport.GetUserAsync(Profile, User, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteData(tenantUser, UserCommandSupport.PrintUserDetail); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Security/User/UserListCliCommand.cs b/src/TALXIS.CLI.Features.Security/User/UserListCliCommand.cs new file mode 100644 index 00000000..56f70bd5 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/User/UserListCliCommand.cs @@ -0,0 +1,60 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.User; + +/// +/// Lists Entra users tenant-wide, or Dataverse environment users when an environment scope is resolved. +/// Usage: txc security user list [--filter <upn-or-name>] [--enabled|--disabled|--all] [--environment <id>] +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List Entra users when no environment is resolved. When --environment is provided or the active connection already targets an environment, list Dataverse environment users instead; in that mode --enabled, --disabled, and --all control the Dataverse user state filter." +)] +public class UserListCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserListCliCommand)); + + [CliOption(Name = "--filter", Description = "Show only users whose user principal name or display name starts with this value. When an environment scope is resolved, this option is ignored because Dataverse user listing currently exposes state filters instead.", Required = false)] + public string? Filter { get; set; } + + [CliOption(Name = "--enabled", Description = "When an environment scope is resolved, show enabled Dataverse users only. This is the default when no Dataverse state flag is supplied.", Required = false)] + public bool Enabled { get; set; } + + [CliOption(Name = "--disabled", Description = "When an environment scope is resolved, show disabled Dataverse users only.", Required = false)] + public bool Disabled { get; set; } + + [CliOption(Name = "--all", Description = "When an environment scope is resolved, show both enabled and disabled Dataverse users.", Required = false)] + public bool All { get; set; } + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + if (scope.HasEnvironment) + { + if (!SecurityPrincipalCommandSupport.TryResolveStateFilter(Enabled, Disabled, All, Logger, out var filter)) + return ExitValidationError; + + var service = TxcServices.Get(); + var rows = await service.ListAsync(Profile, filter, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + OutputFormatter.WriteList(rows, UserCommandSupport.PrintEnvironmentUsersTable); + return ExitSuccess; + } + + if (Enabled || Disabled || All) + { + Logger.LogError("--enabled, --disabled, and --all require --environment or an active environment connection."); + return ExitValidationError; + } + + var users = await UserCommandSupport.ListUsersAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteList(users, UserCommandSupport.PrintUserList); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Security/User/UserRoleAddCliCommand.cs b/src/TALXIS.CLI.Features.Security/User/UserRoleAddCliCommand.cs new file mode 100644 index 00000000..0f33ac07 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/User/UserRoleAddCliCommand.cs @@ -0,0 +1,91 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.User; + +[CliIdempotent] +[CliCommand( + Name = "add", + Description = "Assign a tenant admin role when no environment is resolved. When --environment is provided or the active connection already targets an environment, assign a Dataverse security role in that environment instead." +)] +public class UserRoleAddCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserRoleAddCliCommand)); + + [CliOption(Name = "--user", Description = "User principal name or Entra object ID. With an environment scope, a Dataverse system user GUID is also accepted.", Required = true)] + public string User { get; set; } = null!; + + [CliOption(Name = "--role", Description = "Tenant role name or role ID. With an environment scope, pass a Dataverse security role name or GUID instead.", Required = true)] + public string Role { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + return scope.HasEnvironment + ? await ExecuteEnvironmentAddAsync(scope).ConfigureAwait(false) + : await ExecuteTenantAddAsync().ConfigureAwait(false); + } + + private async Task ExecuteTenantAddAsync() + { + try + { + await UserCommandSupport.AddTenantRoleAsync(Profile, User, Role, CancellationToken.None).ConfigureAwait(false); + + var payload = new { status = "role-added", user = User, role = Role }; + SecurityPrincipalCommandSupport.WriteMutationResult(payload, () => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Role '{Role}' assigned to user '{User}'."); +#pragma warning restore TXC003 + }); + return ExitSuccess; + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } + + private async Task ExecuteEnvironmentAddAsync(SecurityScopeContext scope) + { + var userService = TxcServices.Get(); + var roleService = TxcServices.Get(); + + var user = await UserCommandSupport.ResolveEnvironmentUserAsync(userService, Profile, User, scope.EnvironmentId, Logger, CancellationToken.None).ConfigureAwait(false); + if (user is null) + return ExitValidationError; + + var role = await UserCommandSupport.ResolveEnvironmentRoleAsync(roleService, Profile, Role, scope.EnvironmentId, Logger, CancellationToken.None).ConfigureAwait(false); + if (role is null) + return ExitValidationError; + + var existingRoles = await userService.ListRolesAsync(Profile, User, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + if (existingRoles.Any(r => SecurityPrincipalCommandSupport.IsRoleMatch(r, Role))) + { + OutputFormatter.WriteData( + new { status = "unchanged", userId = user.Id, user = UserCommandSupport.FormatUserLabel(user), roleId = role.Id, role = role.Name, environmentId = scope.EnvironmentId }, + _ => OutputWriter.WriteLine($"Role '{role.Name}' is already assigned to user '{UserCommandSupport.FormatUserLabel(user)}'.")); + return ExitSuccess; + } + + try + { + await userService.AddRoleAsync(Profile, User, Role, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + + OutputFormatter.WriteData( + new { status = "assigned", userId = user.Id, user = UserCommandSupport.FormatUserLabel(user), roleId = role.Id, role = role.Name, environmentId = scope.EnvironmentId }, + _ => OutputWriter.WriteLine($"Role '{role.Name}' assigned to user '{UserCommandSupport.FormatUserLabel(user)}'.")); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Security/User/UserRoleListCliCommand.cs b/src/TALXIS.CLI.Features.Security/User/UserRoleListCliCommand.cs new file mode 100644 index 00000000..b2d2c9df --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/User/UserRoleListCliCommand.cs @@ -0,0 +1,55 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.User; + +/// +/// Lists role assignments for a user. Without an environment scope this shows tenant admin roles only; with an environment scope it shows tenant admin roles and Dataverse environment security roles in separate sections. +/// Usage: txc security user role list --user <upn-or-object-id> [--environment <id>] +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List the user's tenant admin roles when no environment is resolved. When --environment is provided or the active connection already targets an environment, also list the user's Dataverse security roles for that environment under a separate labeled section." +)] +public class UserRoleListCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserRoleListCliCommand)); + + [CliOption(Name = "--user", Description = "User principal name or Entra object ID. With an environment scope, a Dataverse system user GUID is also accepted.", Required = true)] + public string User { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + var tenantAssignments = await UserCommandSupport.ListTenantRolesAsync(Profile, User, CancellationToken.None).ConfigureAwait(false); + if (!scope.HasEnvironment) + { + OutputFormatter.WriteList(tenantAssignments, SecurityPrincipalCommandSupport.WriteRoleTable); + return ExitSuccess; + } + + var userService = TxcServices.Get(); + var environmentAssignments = await userService.ListRolesAsync(Profile, User, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + var payload = new + { + tenantAdminRoles = tenantAssignments, + environmentId = scope.EnvironmentId, + environmentDisplayName = scope.EnvironmentDisplayName, + environmentSecurityRoles = environmentAssignments, + }; + + OutputFormatter.WriteData(payload, _ => + SecurityPrincipalCommandSupport.WriteCombinedRoleSections( + tenantAssignments, + environmentAssignments, + scope, + UserCommandSupport.PrintEnvironmentRolesTable)); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Security/User/UserRoleRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Security/User/UserRoleRemoveCliCommand.cs new file mode 100644 index 00000000..abe5c619 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/User/UserRoleRemoveCliCommand.cs @@ -0,0 +1,94 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.User; + +[CliDestructive("Removes the selected role assignment. With an environment scope this removes a Dataverse security role; otherwise it removes a tenant admin role.")] +[CliCommand( + Name = "remove", + Description = "Remove a tenant admin role when no environment is resolved. When --environment is provided or the active connection already targets an environment, remove a Dataverse security role from that environment instead." +)] +public class UserRoleRemoveCliCommand : SecurityScopedCliCommand, IDestructiveCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserRoleRemoveCliCommand)); + + [CliOption(Name = "--yes", Description = "Skip interactive confirmation.", Required = false)] + public bool Yes { get; set; } + + [CliOption(Name = "--user", Description = "User principal name or Entra object ID. With an environment scope, a Dataverse system user GUID is also accepted.", Required = true)] + public string User { get; set; } = null!; + + [CliOption(Name = "--role", Description = "Tenant role name or role ID. With an environment scope, pass a Dataverse security role name or GUID instead.", Required = true)] + public string Role { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveScopeAsync(Profile, Environment, CancellationToken.None).ConfigureAwait(false); + return scope.HasEnvironment + ? await ExecuteEnvironmentRemoveAsync(scope).ConfigureAwait(false) + : await ExecuteTenantRemoveAsync().ConfigureAwait(false); + } + + private async Task ExecuteTenantRemoveAsync() + { + try + { + await UserCommandSupport.RemoveTenantRoleAsync(Profile, User, Role, CancellationToken.None).ConfigureAwait(false); + var payload = new { status = "role-removed", user = User, role = Role }; + SecurityPrincipalCommandSupport.WriteMutationResult(payload, () => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Role '{Role}' removed from user '{User}'."); +#pragma warning restore TXC003 + }); + return ExitSuccess; + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + } + + private async Task ExecuteEnvironmentRemoveAsync(SecurityScopeContext scope) + { + var userService = TxcServices.Get(); + var roleService = TxcServices.Get(); + + var user = await UserCommandSupport.ResolveEnvironmentUserAsync(userService, Profile, User, scope.EnvironmentId, Logger, CancellationToken.None).ConfigureAwait(false); + if (user is null) + return ExitValidationError; + + var role = await UserCommandSupport.ResolveEnvironmentRoleAsync(roleService, Profile, Role, scope.EnvironmentId, Logger, CancellationToken.None).ConfigureAwait(false); + if (role is null) + return ExitValidationError; + + var existingRoles = await userService.ListRolesAsync(Profile, User, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + if (!existingRoles.Any(r => r.Id == role.Id)) + { + OutputFormatter.WriteData( + new { status = "unchanged", userId = user.Id, user = UserCommandSupport.FormatUserLabel(user), roleId = role.Id, role = role.Name, environmentId = scope.EnvironmentId }, + _ => OutputWriter.WriteLine($"Role '{role.Name}' is not assigned to user '{UserCommandSupport.FormatUserLabel(user)}'.")); + return ExitSuccess; + } + + try + { + await userService.RemoveRoleAsync(Profile, User, Role, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); + } + catch (Exception ex) when (SecurityPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) + { + return exitCode; + } + + OutputFormatter.WriteData( + new { status = "removed", userId = user.Id, user = UserCommandSupport.FormatUserLabel(user), roleId = role.Id, role = role.Name, environmentId = scope.EnvironmentId }, + _ => OutputWriter.WriteLine($"Role '{role.Name}' removed from user '{UserCommandSupport.FormatUserLabel(user)}'.")); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Security/User/UserSelfElevateCliCommand.cs b/src/TALXIS.CLI.Features.Security/User/UserSelfElevateCliCommand.cs new file mode 100644 index 00000000..a79df146 --- /dev/null +++ b/src/TALXIS.CLI.Features.Security/User/UserSelfElevateCliCommand.cs @@ -0,0 +1,36 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Security.User; + +/// +/// Grants the current authenticated caller Dataverse admin access in the selected environment. +/// Usage: txc security user self-elevate [--environment <id>] +/// +[CliIdempotent] +[CliCommand( + Name = "self-elevate", + Description = "Grant the current authenticated caller the environment admin role in a Dataverse environment. This command requires --environment or an active environment connection because there is no tenant-wide equivalent." +)] +public class UserSelfElevateCliCommand : SecurityScopedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserSelfElevateCliCommand)); + + protected override async Task ExecuteAsync() + { + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security user self-elevate", CancellationToken.None).ConfigureAwait(false); + await TxcServices.Get() + .SelfElevateAsync(scope.EnvironmentContext!.Connection, scope.EnvironmentContext.Credential, scope.EnvironmentId!.Value, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteData( + new { status = "elevated", environmentId = scope.EnvironmentId, caller = "current" }, + _ => OutputWriter.WriteLine("Environment admin role applied to the current authenticated caller.")); + + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/User/UserUpdateCliCommand.cs b/src/TALXIS.CLI.Features.Security/User/UserUpdateCliCommand.cs similarity index 56% rename from src/TALXIS.CLI.Features.Environment/User/UserUpdateCliCommand.cs rename to src/TALXIS.CLI.Features.Security/User/UserUpdateCliCommand.cs index 143d9b46..2cf50aeb 100644 --- a/src/TALXIS.CLI.Features.Environment/User/UserUpdateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Security/User/UserUpdateCliCommand.cs @@ -4,21 +4,21 @@ using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.User; +namespace TALXIS.CLI.Features.Security.User; /// /// Enables or disables an existing Dataverse environment user. -/// Usage: txc environment user update --user <upn-or-guid> [--enable|--disable] +/// Usage: txc security user update --user <upn-or-guid> [--enable|--disable] [--environment <id>] /// [CliDestructive("Disabling an environment user removes their active access to the environment until they are enabled again.")] [CliCommand( Name = "update", - Description = "Enable a user or disable a user to remove their active environment access. Resolves the target by user principal name or system user GUID. Specify exactly one of --enable or --disable." + Description = "Enable or disable a Dataverse environment user. This command requires --environment or an active environment connection because there is no tenant-wide user-state mutation equivalent. Specify exactly one of --enable or --disable." )] -#pragma warning disable TXC003 -public class UserUpdateCliCommand : ProfiledCliCommand, IDestructiveCommand +public class UserUpdateCliCommand : SecurityScopedCliCommand, IDestructiveCommand { protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserUpdateCliCommand)); @@ -42,13 +42,9 @@ protected override async Task ExecuteAsync() return ExitValidationError; } + var scope = await SecurityPrincipalCommandSupport.ResolveRequiredEnvironmentScopeAsync(Profile, Environment, "txc security user update", CancellationToken.None).ConfigureAwait(false); var service = TxcServices.Get(); - var user = await UserCliCommandSupport.ResolveUserAsync( - service, - Profile, - User, - Logger, - CancellationToken.None).ConfigureAwait(false); + var user = await UserCommandSupport.ResolveEnvironmentUserAsync(service, Profile, User, scope.EnvironmentId, Logger, CancellationToken.None).ConfigureAwait(false); if (user is null) return ExitValidationError; @@ -57,29 +53,17 @@ protected override async Task ExecuteAsync() { var currentState = targetEnabled ? "enabled" : "disabled"; OutputFormatter.WriteData( - new - { - status = "unchanged", - userId = user.Id, - user = UserCliCommandSupport.FormatUserLabel(user), - enabled = targetEnabled, - }, - _ => OutputWriter.WriteLine($"User '{UserCliCommandSupport.FormatUserLabel(user)}' is already {currentState}.")); + new { status = "unchanged", userId = user.Id, user = UserCommandSupport.FormatUserLabel(user), enabled = targetEnabled, environmentId = scope.EnvironmentId }, + _ => OutputWriter.WriteLine($"User '{UserCommandSupport.FormatUserLabel(user)}' is already {currentState}.")); return ExitSuccess; } - await service.UpdateEnabledStateAsync(Profile, User, targetEnabled, CancellationToken.None).ConfigureAwait(false); + await service.UpdateEnabledStateAsync(Profile, User, targetEnabled, CancellationToken.None, scope.EnvironmentId).ConfigureAwait(false); var newState = targetEnabled ? "enabled" : "disabled"; OutputFormatter.WriteData( - new - { - status = newState, - userId = user.Id, - user = UserCliCommandSupport.FormatUserLabel(user), - enabled = targetEnabled, - }, - _ => OutputWriter.WriteLine($"User '{UserCliCommandSupport.FormatUserLabel(user)}' {newState}.")); + new { status = newState, userId = user.Id, user = UserCommandSupport.FormatUserLabel(user), enabled = targetEnabled, environmentId = scope.EnvironmentId }, + _ => OutputWriter.WriteLine($"User '{UserCommandSupport.FormatUserLabel(user)}' {newState}.")); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Tenant/Role/RoleCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/Role/RoleCliCommand.cs deleted file mode 100644 index e2c41cd1..00000000 --- a/src/TALXIS.CLI.Features.Tenant/Role/RoleCliCommand.cs +++ /dev/null @@ -1,25 +0,0 @@ -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Tenant.Role; - -/// -/// Parent command for the tenant role catalog. -/// Usage: txc tenant role [list|get] -/// -[CliCommand( - Name = "role", - Description = "Browse the tenant role catalog accepted by --role in txc tenant service-principal/user/group role add/remove commands.", - Children = new[] - { - typeof(RoleListCliCommand), - typeof(RoleGetCliCommand) - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class RoleCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/Role/RoleGetCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/Role/RoleGetCliCommand.cs deleted file mode 100644 index f5f422c9..00000000 --- a/src/TALXIS.CLI.Features.Tenant/Role/RoleGetCliCommand.cs +++ /dev/null @@ -1,30 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.Role; - -/// -/// Shows one tenant-assignable role from the catalog accepted by --role in tenant role-assignment commands. -/// Usage: txc tenant role get --role <name-or-guid> -/// -[CliReadOnly] -[CliCommand( - Name = "get", - Description = "Get one tenant role from the catalog accepted by --role in txc tenant service-principal/user/group role add/remove commands." -)] -public class RoleGetCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(RoleGetCliCommand)); - - [CliOption(Name = "--role", Description = "Tenant role name or role id accepted by tenant role-assignment commands.", Required = true)] - public string Role { get; set; } = null!; - - protected override async Task ExecuteAsync() - { - var role = await TenantRoleCommandSupport.GetRoleAsync(Profile, Role, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteData(role, RoleOutput.PrintDetail); - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/Role/RoleListCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/Role/RoleListCliCommand.cs deleted file mode 100644 index 764a6dab..00000000 --- a/src/TALXIS.CLI.Features.Tenant/Role/RoleListCliCommand.cs +++ /dev/null @@ -1,30 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.Role; - -/// -/// Lists tenant-assignable roles that can be passed to --role in tenant role-assignment commands. -/// Usage: txc tenant role list [--filter <name>] -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List tenant roles accepted by --role in txc tenant service-principal/user/group role add/remove commands." -)] -public class RoleListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(RoleListCliCommand)); - - [CliOption(Name = "--filter", Description = "Show only tenant roles whose name or role id contains this substring.", Required = false)] - public string? Filter { get; set; } - - protected override async Task ExecuteAsync() - { - var roles = await TenantRoleCommandSupport.ListRolesAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(roles, RoleOutput.PrintList); - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/Role/TenantRoleCommandSupport.cs b/src/TALXIS.CLI.Features.Tenant/Role/TenantRoleCommandSupport.cs deleted file mode 100644 index d70896a7..00000000 --- a/src/TALXIS.CLI.Features.Tenant/Role/TenantRoleCommandSupport.cs +++ /dev/null @@ -1,29 +0,0 @@ -using TALXIS.CLI.Core.Contracts.PowerPlatform; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Features.Tenant; -using TALXIS.CLI.Platform.PowerPlatform.Control; - -namespace TALXIS.CLI.Features.Tenant.Role; - -internal static class TenantRoleCommandSupport -{ - public static async Task> ListRolesAsync( - string? profile, - string? filter, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); - return await resolver.ListTenantRolesAsync(context.Connection, context.Credential, filter, ct).ConfigureAwait(false); - } - - public static async Task GetRoleAsync( - string? profile, - string role, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); - return await resolver.GetTenantRoleAsync(context.Connection, context.Credential, role, ct).ConfigureAwait(false); - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalCliCommand.cs deleted file mode 100644 index 9388db71..00000000 --- a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalCliCommand.cs +++ /dev/null @@ -1,49 +0,0 @@ -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Tenant.ServicePrincipal; - -/// -/// Parent command for Entra application discovery and tenant-wide role assignment. -/// Usage: txc tenant service-principal [list|get|role] -/// -[CliCommand( - Name = "service-principal", - Description = "Discover Entra applications and manage their tenant-wide role assignments.", - Children = new[] - { - typeof(ServicePrincipalListCliCommand), - typeof(ServicePrincipalGetCliCommand), - typeof(ServicePrincipalRoleCliCommand) - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class ServicePrincipalCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} - -/// -/// Sub-resource for tenant-wide role assignments on an Entra application. -/// Usage: txc tenant service-principal role [list|add|remove] -/// -[CliCommand( - Name = "role", - Description = "Manage tenant-wide role assignments for an Entra application.", - Children = new[] - { - typeof(ServicePrincipalRoleListCliCommand), - typeof(ServicePrincipalRoleAddCliCommand), - typeof(ServicePrincipalRoleRemoveCliCommand) - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class ServicePrincipalRoleCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalGetCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalGetCliCommand.cs deleted file mode 100644 index a66111e7..00000000 --- a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalGetCliCommand.cs +++ /dev/null @@ -1,39 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.ServicePrincipal; - -/// -/// Shows one Entra application by client ID, object ID, or exact display name. -/// Usage: txc tenant service-principal get --service-principal <client-id-or-object-id> -/// -[CliReadOnly] -[CliCommand( - Name = "get", - Description = "Get one Entra application by client ID, object ID, or exact display name." -)] -public class ServicePrincipalGetCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalGetCliCommand)); - - [CliOption(Name = "--service-principal", Description = "Application client ID, service principal object ID, or exact display name.", Required = true)] - public string ServicePrincipal { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteGetAsync(); - - private async Task ExecuteGetAsync() - { - try - { - var app = await TenantServicePrincipalCommandSupport.GetServicePrincipalAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteData(app, TenantServicePrincipalCommandSupport.WriteServicePrincipalDetail); - return ExitSuccess; - } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalListCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalListCliCommand.cs deleted file mode 100644 index 0edd4217..00000000 --- a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalListCliCommand.cs +++ /dev/null @@ -1,30 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.ServicePrincipal; - -/// -/// Lists Entra applications available for tenant-wide role assignment. -/// Usage: txc tenant service-principal list [--filter <name>] -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List Entra applications that can be targeted by txc tenant service-principal role commands." -)] -public class ServicePrincipalListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalListCliCommand)); - - [CliOption(Name = "--filter", Description = "Show only Entra applications whose display name starts with this value.", Required = false)] - public string? Filter { get; set; } - - protected override async Task ExecuteAsync() - { - var rows = await TenantServicePrincipalCommandSupport.ListServicePrincipalsAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(rows, TenantServicePrincipalCommandSupport.WriteServicePrincipalTable); - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalRoleAddCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalRoleAddCliCommand.cs deleted file mode 100644 index 67b66fec..00000000 --- a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalRoleAddCliCommand.cs +++ /dev/null @@ -1,56 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.ServicePrincipal; - -/// -/// Assigns a tenant-wide role to an Entra application. -/// Usage: txc tenant service-principal role add --service-principal <client-id-or-object-id> --role <name-or-guid> -/// -[CliIdempotent] -[CliCommand( - Name = "add", - Description = "Assign a tenant-wide role to an Entra application." -)] -public class ServicePrincipalRoleAddCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalRoleAddCliCommand)); - - [CliOption(Name = "--service-principal", Description = "Application client ID, service principal object ID, or exact display name.", Required = true)] - public string ServicePrincipal { get; set; } = null!; - - [CliOption(Name = "--role", Description = "Tenant role name or GUID. Use 'admin-application' to allow this service principal to call txc environment admin commands non-interactively.", Required = true)] - public string Role { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteAddRoleAsync(); - - private async Task ExecuteAddRoleAsync() - { - try - { - await TenantServicePrincipalCommandSupport.AddAssignmentAsync(Profile, ServicePrincipal, Role, CancellationToken.None).ConfigureAwait(false); - - var payload = new - { - status = "role-added", - servicePrincipal = ServicePrincipal, - role = Role, - }; - - TenantServicePrincipalCommandSupport.WriteMutationResult(payload, () => - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"Role '{Role}' assigned to service principal '{ServicePrincipal}'."); -#pragma warning restore TXC003 - }); - - return ExitSuccess; - } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalRoleListCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalRoleListCliCommand.cs deleted file mode 100644 index 3016463e..00000000 --- a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalRoleListCliCommand.cs +++ /dev/null @@ -1,39 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.ServicePrincipal; - -/// -/// Lists tenant-wide role assignments for an Entra application. -/// Usage: txc tenant service-principal role list --service-principal <client-id-or-object-id> -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List tenant-wide role assignments for an Entra application." -)] -public class ServicePrincipalRoleListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalRoleListCliCommand)); - - [CliOption(Name = "--service-principal", Description = "Application client ID, service principal object ID, or exact display name.", Required = true)] - public string ServicePrincipal { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteListRolesAsync(); - - private async Task ExecuteListRolesAsync() - { - try - { - var rows = await TenantServicePrincipalCommandSupport.ListAssignmentsAsync(Profile, ServicePrincipal, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(rows, TenantServicePrincipalCommandSupport.WriteRoleTable); - return ExitSuccess; - } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalRoleRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalRoleRemoveCliCommand.cs deleted file mode 100644 index 6f43c4eb..00000000 --- a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/ServicePrincipalRoleRemoveCliCommand.cs +++ /dev/null @@ -1,60 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.ServicePrincipal; - -/// -/// Removes a tenant-wide role from an Entra application. -/// Usage: txc tenant service-principal role remove --service-principal <client-id-or-object-id> --role <name-or-guid> --yes -/// -[CliDestructive("Removes the tenant-wide role assignment. If --role admin-application is used, this also revokes the service principal's ability to call txc environment admin commands non-interactively.")] -[CliCommand( - Name = "remove", - Description = "Remove a tenant-wide role from an Entra application." -)] -public class ServicePrincipalRoleRemoveCliCommand : ProfiledCliCommand, IDestructiveCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ServicePrincipalRoleRemoveCliCommand)); - - [CliOption(Name = "--yes", Description = "Skip interactive confirmation.", Required = false)] - public bool Yes { get; set; } - - [CliOption(Name = "--service-principal", Description = "Application client ID, service principal object ID, or exact display name.", Required = true)] - public string ServicePrincipal { get; set; } = null!; - - [CliOption(Name = "--role", Description = "Tenant role name or GUID. Use 'admin-application' to revoke this service principal's ability to call txc environment admin commands non-interactively.", Required = true)] - public string Role { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteRemoveRoleAsync(); - - private async Task ExecuteRemoveRoleAsync() - { - try - { - await TenantServicePrincipalCommandSupport.RemoveAssignmentAsync(Profile, ServicePrincipal, Role, CancellationToken.None).ConfigureAwait(false); - - var payload = new - { - status = "role-removed", - servicePrincipal = ServicePrincipal, - role = Role, - }; - - TenantServicePrincipalCommandSupport.WriteMutationResult(payload, () => - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"Role '{Role}' removed from service principal '{ServicePrincipal}'."); -#pragma warning restore TXC003 - }); - - return ExitSuccess; - } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/TenantServicePrincipalCommandSupport.cs b/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/TenantServicePrincipalCommandSupport.cs deleted file mode 100644 index e9c19547..00000000 --- a/src/TALXIS.CLI.Features.Tenant/ServicePrincipal/TenantServicePrincipalCommandSupport.cs +++ /dev/null @@ -1,201 +0,0 @@ -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; -using TALXIS.CLI.Core.Contracts.PowerPlatform; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Core.Model; -using TALXIS.CLI.Platform.PowerPlatform.Control; -using TALXIS.CLI.Platform.PowerPlatform.Control.Graph; - -namespace TALXIS.CLI.Features.Tenant.ServicePrincipal; - -internal static class TenantServicePrincipalCommandSupport -{ - public static async Task> ListServicePrincipalsAsync( - string? profile, - string? filter, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var graph = TxcServices.Get(); - return await graph.ListServicePrincipalsAsync( - context.Connection, - context.Credential, - BuildListFilter(filter), - top: 100, - ct).ConfigureAwait(false); - } - - public static async Task GetServicePrincipalAsync( - string? profile, - string app, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var graph = TxcServices.Get(); - var matches = await graph.ListServicePrincipalsAsync( - context.Connection, - context.Credential, - BuildExactAppFilter(app), - top: 25, - ct).ConfigureAwait(false); - - var normalized = app.Trim(); - var exactMatches = matches.Where(candidate => MatchesApplication(candidate, normalized)).ToList(); - - if (exactMatches.Count == 0) - throw new TenantPrincipalNotFoundException(PowerPlatformPrincipalType.ApplicationUser, app); - - if (exactMatches.Count > 1) - { - throw new TenantPrincipalAmbiguousException( - PowerPlatformPrincipalType.ApplicationUser, - app, - exactMatches.Select(FormatAppCandidate)); - } - - return exactMatches[0]; - } - - public static async Task> ListAssignmentsAsync( - string? profile, - string app, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); - return await resolver.ListAssignmentsAsync( - context.Connection, - context.Credential, - PowerPlatformPrincipalType.ApplicationUser, - app, - ct).ConfigureAwait(false); - } - - public static async Task AddAssignmentAsync( - string? profile, - string app, - string role, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); - await resolver.AddAssignmentAsync( - context.Connection, - context.Credential, - PowerPlatformPrincipalType.ApplicationUser, - app, - role, - ct).ConfigureAwait(false); - } - - public static async Task RemoveAssignmentAsync( - string? profile, - string app, - string role, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); - await resolver.RemoveAssignmentAsync( - context.Connection, - context.Credential, - PowerPlatformPrincipalType.ApplicationUser, - app, - role, - ct).ConfigureAwait(false); - } - - internal static void WriteServicePrincipalTable(IReadOnlyList rows) - { -#pragma warning disable TXC003 - if (rows.Count == 0) - { - OutputWriter.WriteLine("No tenant service principals found."); - return; - } - - const int objectIdWidth = 36; - const int appIdWidth = 36; - int displayNameWidth = Math.Clamp(rows.Max(r => (r.DisplayName ?? string.Empty).Length), 12, 48); - - string header = - $"{"Application ID".PadRight(appIdWidth)} | " + - $"{"Object ID".PadRight(objectIdWidth)} | " + - "Display Name"; - OutputWriter.WriteLine(header); - OutputWriter.WriteLine(new string('-', header.Length + displayNameWidth)); - - foreach (var row in rows) - { - OutputWriter.WriteLine( - $"{(row.AppId?.ToString() ?? "-").PadRight(appIdWidth)} | " + - $"{row.Id} | " + - $"{TenantPrincipalCommandSupport.Truncate(row.DisplayName ?? string.Empty, displayNameWidth)}"); - } -#pragma warning restore TXC003 - } - - internal static void WriteServicePrincipalDetail(GraphServicePrincipal app) - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"Application ID: {(app.AppId?.ToString() ?? "-")}"); - OutputWriter.WriteLine($"Object ID: {app.Id}"); - OutputWriter.WriteLine($"Display Name: {app.DisplayName ?? "-"}"); -#pragma warning restore TXC003 - } - - internal static void WriteRoleTable(IReadOnlyList rows) - { -#pragma warning disable TXC003 - if (rows.Count == 0) - { - OutputWriter.WriteLine("No tenant service-principal role assignments found."); - return; - } - - int roleWidth = Math.Clamp(rows.Max(r => r.RoleName.Length), 4, 36); - int identifierWidth = Math.Clamp(rows.Max(r => r.RoleIdentifier.Length), 10, 36); - int kindWidth = 11; - - string header = - $"{"Role".PadRight(roleWidth)} | " + - $"{"Identifier".PadRight(identifierWidth)} | " + - $"{"Kind".PadRight(kindWidth)} | " + - "Scope"; - OutputWriter.WriteLine(header); - OutputWriter.WriteLine(new string('-', header.Length + 24)); - - foreach (var row in rows) - { - OutputWriter.WriteLine( - $"{TenantPrincipalCommandSupport.Truncate(row.RoleName, roleWidth).PadRight(roleWidth)} | " + - $"{TenantPrincipalCommandSupport.Truncate(row.RoleIdentifier, identifierWidth).PadRight(identifierWidth)} | " + - $"{(row.IsSynthetic ? "Synthetic" : "Tenant role").PadRight(kindWidth)} | " + - $"{row.Scope}"); - } -#pragma warning restore TXC003 - } - - internal static void WriteMutationResult(T payload, Action textRenderer) - => OutputFormatter.WriteData(payload, _ => textRenderer()); - - private static string? BuildListFilter(string? filter) - { - if (string.IsNullOrWhiteSpace(filter)) - return null; - - return $"startswith(displayName,'{GraphODataFilterSupport.EscapeODataString(filter.Trim())}')"; - } - - private static string BuildExactAppFilter(string app) - => GraphODataFilterSupport.BuildIdentifierFilter(app, ["appId", "id"], ["displayName"]); - - private static bool MatchesApplication(GraphServicePrincipal principal, string input) - => principal.Id.ToString().Equals(input, StringComparison.OrdinalIgnoreCase) - || (principal.AppId?.ToString().Equals(input, StringComparison.OrdinalIgnoreCase) ?? false) - || string.Equals(principal.DisplayName, input, StringComparison.OrdinalIgnoreCase); - - private static string FormatAppCandidate(GraphServicePrincipal principal) - => $"{principal.DisplayName ?? "-"} (appId: {principal.AppId?.ToString() ?? "-"}, id: {principal.Id})"; -} diff --git a/src/TALXIS.CLI.Features.Tenant/TenantCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/TenantCliCommand.cs deleted file mode 100644 index 6e641ed4..00000000 --- a/src/TALXIS.CLI.Features.Tenant/TenantCliCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Tenant; - -[CliCommand( - Name = "tenant", - Description = "Discover and manage tenant-wide resources and role assignments.", - Children = new[] { typeof(Role.RoleCliCommand), typeof(ServicePrincipal.ServicePrincipalCliCommand), typeof(User.UserCliCommand), typeof(Group.GroupCliCommand) }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class TenantCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/User/TenantPrincipalCommandSupport.cs b/src/TALXIS.CLI.Features.Tenant/User/TenantPrincipalCommandSupport.cs deleted file mode 100644 index 98180645..00000000 --- a/src/TALXIS.CLI.Features.Tenant/User/TenantPrincipalCommandSupport.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; -using TALXIS.CLI.Core.Contracts.PowerPlatform; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Core.Model; -using TALXIS.CLI.Platform.PowerPlatform.Control; - -namespace TALXIS.CLI.Features.Tenant; - -internal static class TenantPrincipalCommandSupport -{ - internal static Task ResolveContextAsync(string? profile, CancellationToken ct) - { - var configurationResolver = TxcServices.Get(); - return configurationResolver.ResolveAsync(profile, ct); - } - - internal static bool TryHandleValidationException(ILogger logger, Exception ex, out int exitCode) - { - if (ex is TenantPrincipalAmbiguousException ambiguousPrincipal) - { - logger.LogError("{Error}", ambiguousPrincipal.Message); - foreach (var candidate in ambiguousPrincipal.Candidates) - logger.LogError("Candidate: {Candidate}", candidate); - - exitCode = 2; - return true; - } - - if (ex is TenantRoleAmbiguousException ambiguousRole) - { - logger.LogError("{Error}", ambiguousRole.Message); - foreach (var candidate in ambiguousRole.CandidateNames) - logger.LogError("Candidate: {Candidate}", candidate); - - exitCode = 2; - return true; - } - - if (ex is ArgumentException or InvalidOperationException) - { - logger.LogError("{Error}", ex.Message); - exitCode = 2; - return true; - } - - exitCode = 0; - return false; - } - - internal static void WriteRoleTable(IReadOnlyList assignments) - { -#pragma warning disable TXC003 - if (assignments.Count == 0) - { - OutputWriter.WriteLine("No tenant roles assigned."); - return; - } - - int roleNameWidth = Math.Clamp(assignments.Max(a => a.RoleName.Length), 9, 36); - int roleIdWidth = Math.Clamp(assignments.Max(a => a.RoleIdentifier.Length), 7, 36); - int scopeWidth = Math.Clamp(assignments.Max(a => a.Scope.Length), 5, 48); - - string header = - $"{"Role Name".PadRight(roleNameWidth)} | " + - $"{"Role ID".PadRight(roleIdWidth)} | " + - "Scope"; - OutputWriter.WriteLine(header); - OutputWriter.WriteLine(new string('-', header.Length)); - - foreach (var assignment in assignments) - { - OutputWriter.WriteLine( - $"{Truncate(assignment.RoleName, roleNameWidth).PadRight(roleNameWidth)} | " + - $"{Truncate(assignment.RoleIdentifier, roleIdWidth).PadRight(roleIdWidth)} | " + - $"{Truncate(assignment.Scope, scopeWidth)}"); - } -#pragma warning restore TXC003 - } - - internal static void WriteMutationResult(T payload, Action textRenderer) - => OutputFormatter.WriteData(payload, _ => textRenderer()); - - internal static string Truncate(string value, int maxWidth) - => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; -} diff --git a/src/TALXIS.CLI.Features.Tenant/User/UserCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/User/UserCliCommand.cs deleted file mode 100644 index 3f3c576b..00000000 --- a/src/TALXIS.CLI.Features.Tenant/User/UserCliCommand.cs +++ /dev/null @@ -1,49 +0,0 @@ -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Tenant.User; - -/// -/// Parent command for tenant-wide Entra user discovery and role assignment operations. -/// Usage: txc tenant user [list|get|role] -/// -[CliCommand( - Name = "user", - Description = "Discover Entra users and manage their tenant role assignments.", - Children = new[] - { - typeof(UserListCliCommand), - typeof(UserGetCliCommand), - typeof(UserRoleCliCommand) - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class UserCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} - -/// -/// Sub-resource for tenant-wide role assignments on an Entra user. -/// Usage: txc tenant user role [list|add|remove] -/// -[CliCommand( - Name = "role", - Description = "Manage tenant role assignments for an Entra user.", - Children = new[] - { - typeof(UserRoleListCliCommand), - typeof(UserRoleAddCliCommand), - typeof(UserRoleRemoveCliCommand) - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class UserRoleCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/User/UserCommandSupport.cs b/src/TALXIS.CLI.Features.Tenant/User/UserCommandSupport.cs deleted file mode 100644 index 40bb7f00..00000000 --- a/src/TALXIS.CLI.Features.Tenant/User/UserCommandSupport.cs +++ /dev/null @@ -1,171 +0,0 @@ -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Contracts.PowerPlatform; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Platform.PowerPlatform.Control; -using TALXIS.CLI.Platform.PowerPlatform.Control.Graph; - -namespace TALXIS.CLI.Features.Tenant.User; - -internal static class UserCommandSupport -{ - public static async Task> ListUsersAsync( - string? profile, - string? filter, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var graph = TxcServices.Get(); - return await graph.ListUsersAsync( - context.Connection, - context.Credential, - BuildListFilter(filter), - top: 100, - ct).ConfigureAwait(false); - } - - public static async Task GetUserAsync( - string? profile, - string user, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var graph = TxcServices.Get(); - var matches = await graph.ListUsersAsync( - context.Connection, - context.Credential, - BuildGetFilter(user), - top: 25, - ct).ConfigureAwait(false); - - var exactMatches = matches - .Where(candidate => MatchesUser(candidate, user.Trim())) - .ToList(); - - if (exactMatches.Count == 0) - throw new TenantPrincipalNotFoundException(PowerPlatformPrincipalType.User, user); - - if (exactMatches.Count > 1) - { - throw new TenantPrincipalAmbiguousException( - PowerPlatformPrincipalType.User, - user, - exactMatches.Select(FormatCandidate)); - } - - return exactMatches[0]; - } - - public static async Task> ListRolesAsync( - string? profile, - string user, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); - return await resolver.ListAssignmentsAsync( - context.Connection, - context.Credential, - PowerPlatformPrincipalType.User, - user, - ct).ConfigureAwait(false); - } - - public static async Task AddRoleAsync( - string? profile, - string user, - string role, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); - await resolver.AddAssignmentAsync( - context.Connection, - context.Credential, - PowerPlatformPrincipalType.User, - user, - role, - ct).ConfigureAwait(false); - } - - public static async Task RemoveRoleAsync( - string? profile, - string user, - string role, - CancellationToken ct) - { - var context = await TenantPrincipalCommandSupport.ResolveContextAsync(profile, ct).ConfigureAwait(false); - var resolver = TxcServices.Get(); - await resolver.RemoveAssignmentAsync( - context.Connection, - context.Credential, - PowerPlatformPrincipalType.User, - user, - role, - ct).ConfigureAwait(false); - } - - internal static void PrintUserList(IReadOnlyList users) - { -#pragma warning disable TXC003 - if (users.Count == 0) - { - OutputWriter.WriteLine("No users found."); - return; - } - - int upnWidth = Math.Clamp(users.Max(u => (u.UserPrincipalName ?? string.Empty).Length), 3, 48); - int nameWidth = Math.Clamp(users.Max(u => (u.DisplayName ?? string.Empty).Length), 12, 36); - - string header = - $"{"UPN".PadRight(upnWidth)} | " + - $"{"Display Name".PadRight(nameWidth)} | " + - "Object ID"; - OutputWriter.WriteLine(header); - OutputWriter.WriteLine(new string('-', header.Length)); - - foreach (var user in users) - { - OutputWriter.WriteLine( - $"{Truncate(user.UserPrincipalName ?? string.Empty, upnWidth).PadRight(upnWidth)} | " + - $"{Truncate(user.DisplayName ?? string.Empty, nameWidth).PadRight(nameWidth)} | " + - $"{user.Id}"); - } -#pragma warning restore TXC003 - } - - internal static void PrintUserDetail(GraphUser user) - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"UPN: {user.UserPrincipalName ?? "-"}"); - OutputWriter.WriteLine($"Display Name: {user.DisplayName ?? "-"}"); - OutputWriter.WriteLine($"Object ID: {user.Id}"); -#pragma warning restore TXC003 - } - - private static string? BuildListFilter(string? filter) - { - if (string.IsNullOrWhiteSpace(filter)) - return null; - - var escaped = GraphODataFilterSupport.EscapeODataString(filter.Trim()); - return $"startswith(userPrincipalName,'{escaped}') or startswith(displayName,'{escaped}')"; - } - - private static string BuildGetFilter(string user) - { - ArgumentException.ThrowIfNullOrWhiteSpace(user); - return GraphODataFilterSupport.BuildIdentifierFilter(user, ["id"], ["userPrincipalName"]); - } - - private static bool MatchesUser(GraphUser user, string input) - => user.Id.ToString().Equals(input, StringComparison.OrdinalIgnoreCase) - || string.Equals(user.UserPrincipalName, input, StringComparison.OrdinalIgnoreCase); - - private static string FormatCandidate(GraphUser user) - => string.IsNullOrWhiteSpace(user.UserPrincipalName) - ? $"{user.DisplayName ?? "(no display name)"} ({user.Id})" - : $"{user.UserPrincipalName} ({user.Id})"; - - private static string Truncate(string value, int maxWidth) - => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; -} diff --git a/src/TALXIS.CLI.Features.Tenant/User/UserGetCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/User/UserGetCliCommand.cs deleted file mode 100644 index c2df9b5e..00000000 --- a/src/TALXIS.CLI.Features.Tenant/User/UserGetCliCommand.cs +++ /dev/null @@ -1,30 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.User; - -/// -/// Gets one Entra user by user principal name or object id. -/// Usage: txc tenant user get --user <upn-or-object-id> -/// -[CliReadOnly] -[CliCommand( - Name = "get", - Description = "Get one Entra user by user principal name or object id in the connected tenant." -)] -public class UserGetCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserGetCliCommand)); - - [CliOption(Name = "--user", Description = "User principal name or Entra object id.", Required = true)] - public string User { get; set; } = null!; - - protected override async Task ExecuteAsync() - { - var user = await UserCommandSupport.GetUserAsync(Profile, User, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteData(user, UserCommandSupport.PrintUserDetail); - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/User/UserListCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/User/UserListCliCommand.cs deleted file mode 100644 index a98695c2..00000000 --- a/src/TALXIS.CLI.Features.Tenant/User/UserListCliCommand.cs +++ /dev/null @@ -1,30 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.User; - -/// -/// Lists Entra users available for tenant-wide role assignment operations. -/// Usage: txc tenant user list [--filter <upn-or-name>] -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List Entra users by user principal name or display name in the connected tenant." -)] -public class UserListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserListCliCommand)); - - [CliOption(Name = "--filter", Description = "Show only users whose user principal name or display name starts with this value.", Required = false)] - public string? Filter { get; set; } - - protected override async Task ExecuteAsync() - { - var users = await UserCommandSupport.ListUsersAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(users, UserCommandSupport.PrintUserList); - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/User/UserRoleAddCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/User/UserRoleAddCliCommand.cs deleted file mode 100644 index 7d48e224..00000000 --- a/src/TALXIS.CLI.Features.Tenant/User/UserRoleAddCliCommand.cs +++ /dev/null @@ -1,57 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.User; - -/// -/// Assigns a tenant role to an Entra user. -/// Usage: txc tenant user role add --user <upn-or-object-id> --role <name-or-guid> -/// -[CliIdempotent] -[CliCommand( - Name = "add", - Description = "Assign a tenant role to an Entra user in the connected tenant." -)] -public class UserRoleAddCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserRoleAddCliCommand)); - - [CliOption(Name = "--user", Description = "User principal name or Entra object id.", Required = true)] - public string User { get; set; } = null!; - - [CliOption(Name = "--role", Description = "Tenant role name or role id.", Required = true)] - public string Role { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteAddRoleAsync(); - - private async Task ExecuteAddRoleAsync() - { - try - { - await UserCommandSupport.AddRoleAsync(Profile, User, Role, CancellationToken.None).ConfigureAwait(false); - - var payload = new - { - status = "role-added", - user = User, - role = Role, - }; - - TenantPrincipalCommandSupport.WriteMutationResult(payload, () => - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"Role '{Role}' assigned to user '{User}'."); -#pragma warning restore TXC003 - }); - - return ExitSuccess; - } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/User/UserRoleListCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/User/UserRoleListCliCommand.cs deleted file mode 100644 index 703cc904..00000000 --- a/src/TALXIS.CLI.Features.Tenant/User/UserRoleListCliCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.User; - -/// -/// Lists tenant roles assigned to an Entra user. -/// Usage: txc tenant user role list --user <upn-or-object-id> -/// -[CliReadOnly] -[CliCommand( - Name = "list", - Description = "List tenant roles assigned to an Entra user in the connected tenant." -)] -public class UserRoleListCliCommand : ProfiledCliCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserRoleListCliCommand)); - - [CliOption(Name = "--user", Description = "User principal name or Entra object id.", Required = true)] - public string User { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteListRolesAsync(); - - private async Task ExecuteListRolesAsync() - { - try - { - var assignments = await UserCommandSupport.ListRolesAsync(Profile, User, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(assignments, TenantPrincipalCommandSupport.WriteRoleTable); - return ExitSuccess; - } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Features.Tenant/User/UserRoleRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Tenant/User/UserRoleRemoveCliCommand.cs deleted file mode 100644 index f50a6a31..00000000 --- a/src/TALXIS.CLI.Features.Tenant/User/UserRoleRemoveCliCommand.cs +++ /dev/null @@ -1,61 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; -using TALXIS.CLI.Features.Tenant; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Features.Tenant.User; - -/// -/// Removes a tenant role from an Entra user. -/// Usage: txc tenant user role remove --user <upn-or-object-id> --role <name-or-guid> --yes -/// -[CliDestructive("Permanently removes the tenant role assignment from the user.")] -[CliCommand( - Name = "remove", - Description = "Remove a tenant role from an Entra user in the connected tenant." -)] -public class UserRoleRemoveCliCommand : ProfiledCliCommand, IDestructiveCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserRoleRemoveCliCommand)); - - [CliOption(Name = "--yes", Description = "Skip interactive confirmation.", Required = false)] - public bool Yes { get; set; } - - [CliOption(Name = "--user", Description = "User principal name or Entra object id.", Required = true)] - public string User { get; set; } = null!; - - [CliOption(Name = "--role", Description = "Tenant role name or role id.", Required = true)] - public string Role { get; set; } = null!; - - protected override Task ExecuteAsync() => ExecuteRemoveRoleAsync(); - - private async Task ExecuteRemoveRoleAsync() - { - try - { - await UserCommandSupport.RemoveRoleAsync(Profile, User, Role, CancellationToken.None).ConfigureAwait(false); - - var payload = new - { - status = "role-removed", - user = User, - role = Role, - }; - - TenantPrincipalCommandSupport.WriteMutationResult(payload, () => - { -#pragma warning disable TXC003 - OutputWriter.WriteLine($"Role '{Role}' removed from user '{User}'."); -#pragma warning restore TXC003 - }); - - return ExitSuccess; - } - catch (Exception ex) when (TenantPrincipalCommandSupport.TryHandleValidationException(Logger, ex, out var exitCode)) - { - return exitCode; - } - } -} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseRoleService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseRoleService.cs index 0e31b3e5..69bc9d6c 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseRoleService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseRoleService.cs @@ -9,18 +9,22 @@ internal sealed class DataverseRoleService : IDataverseRoleService public async Task> ListAsync( string? profileName, string? filter, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.ListRolesAsync(conn.Client, filter, ct).ConfigureAwait(false); } public async Task GetAsync( string? profileName, string nameOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.GetRoleAsync(conn.Client, nameOrGuid, ct).ConfigureAwait(false); } } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseServicePrincipalService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseServicePrincipalService.cs index b276572f..1964785b 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseServicePrincipalService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseServicePrincipalService.cs @@ -9,27 +9,33 @@ internal sealed class DataverseServicePrincipalService : IDataverseServicePrinci public async Task> ListAsync( string? profileName, DataverseSecurityPrincipalStateFilter filter, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.ListServicePrincipalsAsync(conn.Client, filter, ct).ConfigureAwait(false); } public async Task GetAsync( string? profileName, string clientIdOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.GetServicePrincipalAsync(conn.Client, clientIdOrGuid, ct).ConfigureAwait(false); } public async Task CreateAsync( string? profileName, DataverseServicePrincipalCreateOptions options, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.CreateServicePrincipalAsync(conn.Client, options, ct).ConfigureAwait(false); } @@ -37,27 +43,33 @@ public async Task UpdateEnabledStateAsync( string? profileName, string clientIdOrGuid, bool enabled, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.UpdateServicePrincipalEnabledStateAsync(conn.Client, clientIdOrGuid, enabled, ct).ConfigureAwait(false); } public async Task DeleteAsync( string? profileName, string clientIdOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.DeleteServicePrincipalAsync(conn.Client, clientIdOrGuid, ct).ConfigureAwait(false); } public async Task> ListRolesAsync( string? profileName, string clientIdOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.ListServicePrincipalRolesAsync(conn.Client, clientIdOrGuid, ct).ConfigureAwait(false); } @@ -65,9 +77,11 @@ public async Task AddRoleAsync( string? profileName, string clientIdOrGuid, string roleNameOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.AddServicePrincipalRoleAsync(conn.Client, clientIdOrGuid, roleNameOrGuid, ct).ConfigureAwait(false); } @@ -75,9 +89,11 @@ public async Task RemoveRoleAsync( string? profileName, string clientIdOrGuid, string roleNameOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.RemoveServicePrincipalRoleAsync(conn.Client, clientIdOrGuid, roleNameOrGuid, ct).ConfigureAwait(false); } } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseTeamService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseTeamService.cs index 2ed3ce42..2630c5d6 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseTeamService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseTeamService.cs @@ -8,45 +8,55 @@ internal sealed class DataverseTeamService : IDataverseTeamService { public async Task> ListAsync( string? profileName, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.ListTeamsAsync(conn.Client, ct).ConfigureAwait(false); } public async Task GetAsync( string? profileName, string nameOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.GetTeamAsync(conn.Client, nameOrGuid, ct).ConfigureAwait(false); } public async Task CreateAsync( string? profileName, DataverseTeamCreateOptions options, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.CreateTeamAsync(conn.Client, options, ct).ConfigureAwait(false); } public async Task DeleteAsync( string? profileName, string nameOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.DeleteTeamAsync(conn.Client, nameOrGuid, ct).ConfigureAwait(false); } public async Task> ListMembersAsync( string? profileName, string teamIdOrName, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.ListTeamMembersAsync(conn.Client, teamIdOrName, ct).ConfigureAwait(false); } @@ -54,9 +64,11 @@ public async Task AddMemberAsync( string? profileName, string teamIdOrName, string userIdOrUpn, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.AddTeamMemberAsync(conn.Client, teamIdOrName, userIdOrUpn, ct).ConfigureAwait(false); } @@ -64,18 +76,22 @@ public async Task RemoveMemberAsync( string? profileName, string teamIdOrName, string userIdOrUpn, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.RemoveTeamMemberAsync(conn.Client, teamIdOrName, userIdOrUpn, ct).ConfigureAwait(false); } public async Task> ListRolesAsync( string? profileName, string teamIdOrName, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.ListTeamRolesAsync(conn.Client, teamIdOrName, ct).ConfigureAwait(false); } @@ -83,9 +99,11 @@ public async Task AddRoleAsync( string? profileName, string teamIdOrName, string roleNameOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.AddTeamRoleAsync(conn.Client, teamIdOrName, roleNameOrGuid, ct).ConfigureAwait(false); } @@ -93,9 +111,11 @@ public async Task RemoveRoleAsync( string? profileName, string teamIdOrName, string roleNameOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.RemoveTeamRoleAsync(conn.Client, teamIdOrName, roleNameOrGuid, ct).ConfigureAwait(false); } } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseUserService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseUserService.cs index b401031c..c4370a51 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseUserService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseUserService.cs @@ -9,18 +9,22 @@ internal sealed class DataverseUserService : IDataverseUserService public async Task> ListAsync( string? profileName, DataverseSecurityPrincipalStateFilter filter, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.ListRegularUsersAsync(conn.Client, filter, ct).ConfigureAwait(false); } public async Task GetAsync( string? profileName, string userIdOrUpn, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.GetRegularUserAsync(conn.Client, userIdOrUpn, ct).ConfigureAwait(false); } @@ -28,18 +32,22 @@ public async Task UpdateEnabledStateAsync( string? profileName, string userIdOrUpn, bool enabled, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.UpdateRegularUserEnabledStateAsync(conn.Client, userIdOrUpn, enabled, ct).ConfigureAwait(false); } public async Task> ListRolesAsync( string? profileName, string userIdOrUpn, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); return await DataverseSecurityPrincipalManager.ListRegularUserRolesAsync(conn.Client, userIdOrUpn, ct).ConfigureAwait(false); } @@ -47,9 +55,11 @@ public async Task AddRoleAsync( string? profileName, string userIdOrUpn, string roleNameOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.AddRegularUserRoleAsync(conn.Client, userIdOrUpn, roleNameOrGuid, ct).ConfigureAwait(false); } @@ -57,9 +67,11 @@ public async Task RemoveRoleAsync( string? profileName, string userIdOrUpn, string roleNameOrGuid, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { - using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var context = await DataverseScopedCommandSupport.ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + using var conn = await DataverseCommandBridge.ConnectAsync(context, ct).ConfigureAwait(false); await DataverseSecurityPrincipalManager.RemoveRegularUserRoleAsync(conn.Client, userIdOrUpn, roleNameOrGuid, ct).ConfigureAwait(false); } } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs index 61167d2e..7aa47955 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using TALXIS.CLI.Platform.Dataverse.Runtime; using TALXIS.CLI.Platform.Dataverse.Runtime.Msal; using TALXIS.CLI.Platform.PowerPlatform.Control; +using TALXIS.CLI.Platform.PowerPlatform.Control.Governance; using TALXIS.CLI.Platform.PowerPlatform.Control.Graph; using TALXIS.CLI.Platform.PowerPlatform.Control.PowerPlatformRbac; using TALXIS.CLI.Platform.PowerPlatform.Control.Strategies; @@ -57,6 +58,12 @@ public static IServiceCollection AddTxcDataverseProvider(this IServiceCollection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); @@ -64,7 +71,7 @@ public static IServiceCollection AddTxcDataverseProvider(this IServiceCollection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton ConnectAsync(string? profileName, CancellationToken ct) { var resolver = TxcServices.Get(); - var factory = TxcServices.Get(); var context = await resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); - return await factory.ConnectAsync(context, ct).ConfigureAwait(false); + return await ConnectAsync(context, ct).ConfigureAwait(false); + } + + public static Task ConnectAsync(ResolvedProfileContext context, CancellationToken ct) + { + var factory = TxcServices.Get(); + return factory.ConnectAsync(context, ct); } /// diff --git a/src/TALXIS.CLI.Platform.Dataverse.Runtime/Runtime/DataverseScopedCommandSupport.cs b/src/TALXIS.CLI.Platform.Dataverse.Runtime/Runtime/DataverseScopedCommandSupport.cs new file mode 100644 index 00000000..25528527 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Runtime/Runtime/DataverseScopedCommandSupport.cs @@ -0,0 +1,56 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control; + +namespace TALXIS.CLI.Platform.Dataverse.Runtime; + +public static class DataverseScopedCommandSupport +{ + public static async Task ResolveContextAsync( + string? profileName, + Guid? environmentId, + CancellationToken ct) + { + var resolver = TxcServices.Get(); + var context = await resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + if (!environmentId.HasValue) + return context; + + var catalog = TxcServices.Get(); + var environment = (await catalog.ListAsync(context.Connection, context.Credential, ct).ConfigureAwait(false)) + .SingleOrDefault(candidate => candidate.EnvironmentId == environmentId.Value); + + if (environment is null) + { + throw new InvalidOperationException( + $"Power Platform environment '{environmentId}' was not found or is not accessible with the resolved profile."); + } + + return new ResolvedProfileContext( + context.Profile, + CloneConnection(context.Connection, environment), + context.Credential, + context.Source); + } + + private static Connection CloneConnection(Connection source, PowerPlatformEnvironmentSummary environment) + => new() + { + Id = source.Id, + Provider = source.Provider, + Description = source.Description, + EnvironmentUrl = environment.EnvironmentUrl.AbsoluteUri, + OrganizationId = environment.OrganizationId?.ToString(), + EnvironmentId = environment.EnvironmentId, + Cloud = source.Cloud, + TenantId = source.TenantId, + DisplayName = environment.DisplayName, + EnvironmentType = environment.EnvironmentType, + CreatedAt = source.CreatedAt, + UpdatedAt = source.UpdatedAt, + ExtraFields = source.ExtraFields is null + ? null + : new Dictionary(source.ExtraFields, StringComparer.OrdinalIgnoreCase), + }; +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentUserProvisioningService.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentUserProvisioningService.cs index 82bcc18e..4fc851c7 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentUserProvisioningService.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/EnvironmentUserProvisioningService.cs @@ -10,7 +10,7 @@ namespace TALXIS.CLI.Platform.PowerPlatform.Control; /// Implements by resolving /// the target Entra user via Microsoft Graph and provisioning them into the /// environment via the BAP admin addUser endpoint. Backs -/// txc environment user add. +/// txc security user add --environment .... /// public sealed class EnvironmentUserProvisioningService : IEnvironmentUserProvisioningService { @@ -38,21 +38,58 @@ public EnvironmentUserProvisioningService( public async Task ProvisionUserAsync( string? profileName, string userIdOrUpn, - CancellationToken ct) + CancellationToken ct, + Guid? environmentId = null) { ArgumentException.ThrowIfNullOrWhiteSpace(userIdOrUpn); - var ctx = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); - var environmentId = await ResolveEnvironmentIdAsync(ctx.Connection, ctx.Credential, ct).ConfigureAwait(false); + var ctx = await ResolveContextAsync(profileName, environmentId, ct).ConfigureAwait(false); + var resolvedEnvironmentId = await ResolveEnvironmentIdAsync(ctx.Connection, ctx.Credential, ct).ConfigureAwait(false); var (aadObjectId, upn, displayName) = await ResolveGraphUserAsync(ctx.Connection, ctx.Credential, userIdOrUpn, ct) .ConfigureAwait(false); - await _bap.AddUserToEnvironmentAsync(ctx.Connection, ctx.Credential, environmentId, aadObjectId, ct) + await _bap.AddUserToEnvironmentAsync(ctx.Connection, ctx.Credential, resolvedEnvironmentId, aadObjectId, ct) .ConfigureAwait(false); return new EnvironmentUserProvisionResult(aadObjectId, upn, displayName); } + private async Task ResolveContextAsync( + string? profileName, + Guid? environmentId, + CancellationToken ct) + { + var context = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + if (!environmentId.HasValue) + return context; + + var environment = (await _catalog.ListAsync(context.Connection, context.Credential, ct).ConfigureAwait(false)) + .SingleOrDefault(candidate => candidate.EnvironmentId == environmentId.Value) + ?? throw new InvalidOperationException( + $"Power Platform environment '{environmentId}' was not found or is not accessible with the resolved profile."); + + return new ResolvedProfileContext( + context.Profile, + new Connection + { + Id = context.Connection.Id, + Provider = context.Connection.Provider, + Description = context.Connection.Description, + EnvironmentUrl = environment.EnvironmentUrl.AbsoluteUri, + OrganizationId = environment.OrganizationId?.ToString(), + EnvironmentId = environment.EnvironmentId, + Cloud = context.Connection.Cloud, + TenantId = context.Connection.TenantId, + DisplayName = environment.DisplayName, + EnvironmentType = environment.EnvironmentType, + CreatedAt = context.Connection.CreatedAt, + UpdatedAt = context.Connection.UpdatedAt, + ExtraFields = context.Connection.ExtraFields, + }, + context.Credential, + context.Source); + } + private async Task<(Guid AadObjectId, string? UserPrincipalName, string? DisplayName)> ResolveGraphUserAsync( Connection connection, Credential credential, diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Governance/PowerPlatformEnvironmentGroupClient.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Governance/PowerPlatformEnvironmentGroupClient.cs new file mode 100644 index 00000000..63967f19 --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Governance/PowerPlatformEnvironmentGroupClient.cs @@ -0,0 +1,335 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control; +using TALXIS.CLI.Platform.PowerPlatform.Control.PowerPlatformRbac; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control.Governance; + +/// +/// Thin authenticated client over the environment-group management endpoints +/// under api.powerplatform.com/environmentmanagement/environmentGroups. +/// Membership mutations (/ +/// ) +/// are asynchronous on the service side (202 Accepted + an operation +/// to poll); this client polls the operation to completion before returning. +/// +public sealed class PowerPlatformEnvironmentGroupClient : IPowerPlatformEnvironmentGroupClient +{ + private const string ApiVersion = "2024-10-01"; + private static readonly Uri PowerPlatformApiAudience = new("https://api.powerplatform.com/"); + private static readonly TimeSpan OperationPollInterval = TimeSpan.FromSeconds(2); + private static readonly TimeSpan OperationPollTimeout = TimeSpan.FromMinutes(5); + + private readonly IAccessTokenService _tokens; + private readonly IHttpClientFactoryWrapper _httpFactory; + + public PowerPlatformEnvironmentGroupClient( + IAccessTokenService tokens, + IHttpClientFactoryWrapper? httpFactory = null) + { + _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); + _httpFactory = httpFactory ?? DefaultHttpClientFactoryWrapper.Instance; + } + + public async Task> ListAsync( + Connection connection, Credential credential, CancellationToken ct) + { + var initialRequestUri = BuildUri(connection, $"environmentmanagement/environmentGroups?api-version={ApiVersion}"); + + return await ODataPagingSupport.FetchAllPagesAsync( + initialRequestUri, + (requestUri, pageCt) => SendForBodyAsync(connection, credential, HttpMethod.Get, requestUri, jsonBody: null, pageCt), + item => TryParseEnvironmentGroup(item, out var group) ? group : null, + "Environment group payload did not contain a 'value' array.", + ct).ConfigureAwait(false); + } + + public async Task GetAsync( + Connection connection, Credential credential, Guid environmentGroupId, CancellationToken ct) + { + var body = await SendForBodyAsync( + connection, + credential, + HttpMethod.Get, + BuildUri(connection, $"environmentmanagement/environmentGroups/{environmentGroupId}?api-version={ApiVersion}"), + jsonBody: null, + ct).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(body)) + return null; + + using var document = JsonDocument.Parse(body); + return TryParseEnvironmentGroup(document.RootElement, out var group) ? group : null; + } + + public async Task CreateAsync( + Connection connection, Credential credential, PowerPlatformEnvironmentGroupCreateOptions options, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.DisplayName); + + var requestBody = new + { + displayName = options.DisplayName, + description = options.Description, + }; + + var responseBody = await SendForBodyAsync( + connection, + credential, + HttpMethod.Post, + BuildUri(connection, $"environmentmanagement/environmentGroups?api-version={ApiVersion}"), + requestBody, + ct).ConfigureAwait(false); + + using var document = JsonDocument.Parse(responseBody); + return TryParseEnvironmentGroup(document.RootElement, out var group) + ? group + : throw new InvalidOperationException("Environment group creation response could not be parsed."); + } + + public async Task UpdateAsync( + Connection connection, Credential credential, Guid environmentGroupId, PowerPlatformEnvironmentGroupUpdateOptions options, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(options); + + var requestBody = new Dictionary(); + if (options.DisplayName is not null) + requestBody["displayName"] = options.DisplayName; + if (options.Description is not null) + requestBody["description"] = options.Description; + + var responseBody = await SendForBodyAsync( + connection, + credential, + HttpMethod.Patch, + BuildUri(connection, $"environmentmanagement/environmentGroups/{environmentGroupId}?api-version={ApiVersion}"), + requestBody, + ct).ConfigureAwait(false); + + using var document = JsonDocument.Parse(responseBody); + return TryParseEnvironmentGroup(document.RootElement, out var group) + ? group + : throw new InvalidOperationException("Environment group update response could not be parsed."); + } + + public Task DeleteAsync(Connection connection, Credential credential, Guid environmentGroupId, CancellationToken ct) + => SendForBodyAsync( + connection, + credential, + HttpMethod.Delete, + BuildUri(connection, $"environmentmanagement/environmentGroups/{environmentGroupId}?api-version={ApiVersion}"), + jsonBody: null, + ct); + + public async Task AddEnvironmentAsync( + Connection connection, Credential credential, Guid environmentGroupId, Guid environmentId, CancellationToken ct) + { + await SendAndAwaitOperationAsync( + connection, + credential, + HttpMethod.Post, + BuildUri(connection, $"environmentmanagement/environmentGroups/{environmentGroupId}/addEnvironment/{environmentId}?api-version={ApiVersion}"), + ct).ConfigureAwait(false); + } + + public async Task RemoveEnvironmentAsync( + Connection connection, Credential credential, Guid environmentGroupId, Guid environmentId, CancellationToken ct) + { + await SendAndAwaitOperationAsync( + connection, + credential, + HttpMethod.Post, + BuildUri(connection, $"environmentmanagement/environmentGroups/{environmentGroupId}/removeEnvironment/{environmentId}?api-version={ApiVersion}"), + ct).ConfigureAwait(false); + } + + /// + /// Sends an async (202-returning) request and polls the returned + /// operation-status URL (Location/Operation-Location + /// header, per the standard Azure async-operation pattern) until it + /// reports completion or elapses. + /// + private async Task SendAndAwaitOperationAsync( + Connection connection, Credential credential, HttpMethod method, Uri requestUri, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + + var token = await _tokens.AcquireForResourceAsync(connection, credential, PowerPlatformApiAudience, ct) + .ConfigureAwait(false); + + using var http = _httpFactory.Create(); + using var request = new HttpRequestMessage(method, requestUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + if (response.StatusCode != System.Net.HttpStatusCode.Accepted) + { + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Environment group membership request failed ({(int)response.StatusCode} {response.ReasonPhrase}) against '{requestUri}': {PowerPlatformRbacClient.Truncate(body, 500)}"); + } + + return; + } + + var operationUri = response.Headers.Location + ?? (response.Headers.TryGetValues("Operation-Location", out var values) ? new Uri(values.First()) : null); + + if (operationUri is null) + return; + + var deadline = DateTimeOffset.UtcNow + OperationPollTimeout; + while (DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(OperationPollInterval, ct).ConfigureAwait(false); + + using var pollRequest = new HttpRequestMessage(HttpMethod.Get, operationUri); + pollRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using var pollResponse = await http.SendAsync(pollRequest, ct).ConfigureAwait(false); + var pollBody = await pollResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + if (pollResponse.StatusCode != System.Net.HttpStatusCode.Accepted) + { + if (!pollResponse.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Environment group membership operation failed ({(int)pollResponse.StatusCode} {pollResponse.ReasonPhrase}): {PowerPlatformRbacClient.Truncate(pollBody, 500)}"); + } + + return; + } + } + + throw new TimeoutException( + $"Environment group membership operation did not complete within {OperationPollTimeout}."); + } + + private async Task SendForBodyAsync( + Connection connection, Credential credential, HttpMethod method, Uri requestUri, object? jsonBody, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + + var token = await _tokens.AcquireForResourceAsync(connection, credential, PowerPlatformApiAudience, ct) + .ConfigureAwait(false); + + using var http = _httpFactory.Create(); + using var request = new HttpRequestMessage(method, requestUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (jsonBody is not null) + { + request.Content = new StringContent(JsonSerializer.Serialize(jsonBody), Encoding.UTF8, "application/json"); + } + + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseContentRead, ct) + .ConfigureAwait(false); + var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Environment group request failed ({(int)response.StatusCode} {response.ReasonPhrase}) against '{requestUri}': {PowerPlatformRbacClient.Truncate(responseBody, 500)}"); + } + + return responseBody; + } + + private static Uri BuildUri(Connection connection, string relativePath) + => new(GetBaseUri(connection.Cloud ?? CloudInstance.Public), relativePath); + + private static Uri GetBaseUri(CloudInstance cloud) => cloud switch + { + CloudInstance.Public or CloudInstance.Gcc => new Uri("https://api.powerplatform.com/"), + _ => throw new NotSupportedException( + $"Environment groups are not wired for cloud '{cloud}' in this release."), + }; + + private static bool TryParseEnvironmentGroup(JsonElement item, out PowerPlatformEnvironmentGroup group) + { + group = null!; + + if (!TryReadGuid(item, "id", out var id) || !TryReadString(item, "displayName", out var displayName)) + return false; + + var environmentIds = new List(); + if (item.TryGetProperty("environments", out var environments) && environments.ValueKind == JsonValueKind.Array) + { + foreach (var env in environments.EnumerateArray()) + { + if (env.ValueKind == JsonValueKind.Object + && env.TryGetProperty("id", out var envId) + && envId.ValueKind == JsonValueKind.String + && Guid.TryParse(envId.GetString(), out var parsedEnvId)) + { + environmentIds.Add(parsedEnvId); + } + else if (env.ValueKind == JsonValueKind.String && Guid.TryParse(env.GetString(), out var directEnvId)) + { + environmentIds.Add(directEnvId); + } + } + } + + group = new PowerPlatformEnvironmentGroup( + id, + displayName, + TryReadOptionalString(item, "description"), + TryReadOptionalDateTimeOffset(item, "createdOn"), + TryReadOptionalGuid(item, "createdByPrincipalObjectId"), + TryReadOptionalDateTimeOffset(item, "lastModifiedOn"), + environmentIds); + return true; + } + + private static bool TryReadGuid(JsonElement element, string propertyName, out Guid value) + { + value = default; + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + return false; + + return Guid.TryParse(property.GetString(), out value); + } + + private static Guid? TryReadOptionalGuid(JsonElement element, string propertyName) + => TryReadGuid(element, propertyName, out var value) ? value : null; + + private static bool TryReadString(JsonElement element, string propertyName, out string value) + { + value = string.Empty; + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + return false; + + var raw = property.GetString(); + if (string.IsNullOrWhiteSpace(raw)) + return false; + + value = raw.Trim(); + return true; + } + + private static string? TryReadOptionalString(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString()?.Trim() + : null; + + private static DateTimeOffset? TryReadOptionalDateTimeOffset(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse(property.GetString(), out var value) + ? value + : null; +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Governance/PowerPlatformEnvironmentGroupRoleClient.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Governance/PowerPlatformEnvironmentGroupRoleClient.cs new file mode 100644 index 00000000..6bf83b7c --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Governance/PowerPlatformEnvironmentGroupRoleClient.cs @@ -0,0 +1,65 @@ +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control.PowerPlatformRbac; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control.Governance; + +/// +/// Thin wrapper over 's dedicated +/// environment-group role-assignment methods +/// (authorization/environmentGroups/{id}/roleAssignments). Unlike +/// tenant-scoped assignments, environment-group assignments are NOT +/// reachable via the generic authorization/roleAssignments?scope= +/// query filter - Microsoft's REST API reference confirms a dedicated, +/// path-routed resource for this scope instead, with no scope field +/// in the request body (the environment group id in the URL implies it). +/// +public sealed class PowerPlatformEnvironmentGroupRoleClient : IPowerPlatformEnvironmentGroupRoleClient +{ + private readonly PowerPlatformRbacClient _rbac; + + public PowerPlatformEnvironmentGroupRoleClient(PowerPlatformRbacClient rbac) + { + _rbac = rbac ?? throw new ArgumentNullException(nameof(rbac)); + } + + public async Task> ListAsync( + Connection connection, Credential credential, Guid environmentGroupId, CancellationToken ct) + { + var assignments = await _rbac.ListEnvironmentGroupRoleAssignmentsAsync( + connection, credential, environmentGroupId, ct).ConfigureAwait(false); + + return assignments.Select(a => ToContract(a, environmentGroupId)).ToList(); + } + + public async Task AddAsync( + Connection connection, Credential credential, Guid environmentGroupId, + PowerPlatformPrincipalType principalType, Guid principalObjectId, Guid roleDefinitionId, CancellationToken ct) + { + var assignment = await _rbac.AddEnvironmentGroupRoleAssignmentAsync( + connection, credential, environmentGroupId, principalType, principalObjectId, roleDefinitionId, ct) + .ConfigureAwait(false); + + if (assignment is null) + { + throw new InvalidOperationException( + $"Power Platform did not return a role assignment payload after adding the role to environment group '{environmentGroupId}'."); + } + + return ToContract(assignment, environmentGroupId); + } + + public Task RemoveAsync( + Connection connection, Credential credential, Guid environmentGroupId, string roleAssignmentId, CancellationToken ct) + => _rbac.RemoveEnvironmentGroupRoleAssignmentAsync(connection, credential, environmentGroupId, roleAssignmentId, ct); + + private static PowerPlatformEnvironmentGroupRoleAssignment ToContract(PowerPlatformRbacRoleAssignment assignment, Guid environmentGroupId) + => new( + assignment.RoleAssignmentId, + environmentGroupId, + assignment.PrincipalType, + assignment.PrincipalObjectId, + assignment.RoleDefinitionId, + assignment.CreatedOn, + assignment.ExpiresOn); +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Governance/PowerPlatformPolicyRuleClient.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Governance/PowerPlatformPolicyRuleClient.cs new file mode 100644 index 00000000..2e0ab435 --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Governance/PowerPlatformPolicyRuleClient.cs @@ -0,0 +1,358 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control.PowerPlatformRbac; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control.Governance; + +/// +/// Thin authenticated client over the rule-based-policy CRUD and assignment +/// endpoints under api.powerplatform.com/governance/ruleBasedPolicies +/// (api-version 2024-10-01, confirmed from the official REST API reference - +/// see remarks for the two +/// operations this API does not currently expose). +/// +public sealed class PowerPlatformPolicyRuleClient : IPowerPlatformPolicyRuleClient +{ + private const string ApiVersion = "2024-10-01"; + private static readonly Uri PowerPlatformApiAudience = new("https://api.powerplatform.com/"); + + private readonly IAccessTokenService _tokens; + private readonly IHttpClientFactoryWrapper _httpFactory; + + public PowerPlatformPolicyRuleClient( + IAccessTokenService tokens, + IHttpClientFactoryWrapper? httpFactory = null) + { + _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); + _httpFactory = httpFactory ?? DefaultHttpClientFactoryWrapper.Instance; + } + + public async Task> ListAsync( + Connection connection, Credential credential, CancellationToken ct) + { + var initialRequestUri = BuildUri(connection, $"governance/ruleBasedPolicies?api-version={ApiVersion}"); + + return await ODataPagingSupport.FetchAllPagesAsync( + initialRequestUri, + (requestUri, pageCt) => SendForBodyAsync(connection, credential, HttpMethod.Get, requestUri, jsonBody: null, pageCt), + item => TryParsePolicy(item, out var policy) ? policy : null, + "Rule-based policy payload did not contain a 'value' array.", + ct).ConfigureAwait(false); + } + + public async Task GetAsync( + Connection connection, Credential credential, Guid policyId, CancellationToken ct) + { + var body = await SendForBodyAsync( + connection, + credential, + HttpMethod.Get, + BuildUri(connection, $"governance/ruleBasedPolicies/{policyId}?api-version={ApiVersion}"), + jsonBody: null, + ct).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(body)) + return null; + + using var document = JsonDocument.Parse(body); + return TryParsePolicy(document.RootElement, out var policy) ? policy : null; + } + + public async Task CreateAsync( + Connection connection, Credential credential, PowerPlatformPolicyCreateOptions options, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.Name); + + var requestBody = new + { + name = options.Name, + ruleSets = options.RuleSets.Select(ToWireRuleSet).ToArray(), + }; + + var responseBody = await SendForBodyAsync( + connection, + credential, + HttpMethod.Post, + BuildUri(connection, $"governance/ruleBasedPolicies?api-version={ApiVersion}"), + requestBody, + ct).ConfigureAwait(false); + + using var document = JsonDocument.Parse(responseBody); + return TryParsePolicy(document.RootElement, out var policy) + ? policy + : throw new InvalidOperationException("Rule-based policy creation response could not be parsed."); + } + + public async Task UpdateAsync( + Connection connection, Credential credential, Guid policyId, PowerPlatformPolicyPatchOptions options, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(options); + + var requestBody = new Dictionary(); + if (options.Name is not null) + requestBody["name"] = options.Name; + if (options.RuleSets is not null) + requestBody["ruleSets"] = options.RuleSets.Select(ToWireRuleSet).ToArray(); + + var responseBody = await SendForBodyAsync( + connection, + credential, + HttpMethod.Patch, + BuildUri(connection, $"governance/ruleBasedPolicies/{policyId}?api-version={ApiVersion}"), + requestBody, + ct).ConfigureAwait(false); + + using var document = JsonDocument.Parse(responseBody); + return TryParsePolicy(document.RootElement, out var policy) + ? policy + : throw new InvalidOperationException("Rule-based policy update response could not be parsed."); + } + + public async Task RemoveRuleSetAsync( + Connection connection, Credential credential, Guid policyId, string ruleSetId, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ruleSetId); + + // The removeRule endpoint identifies which rule set(s) to remove by + // matching entries in the same PolicyRequest {name, ruleSets} shape + // used everywhere else - only "id" is required to identify a match. + var requestBody = new { ruleSets = new[] { new { id = ruleSetId } } }; + + var responseBody = await SendForBodyAsync( + connection, + credential, + HttpMethod.Patch, + BuildUri(connection, $"governance/ruleBasedPolicies/{policyId}/removeRule?api-version={ApiVersion}"), + requestBody, + ct).ConfigureAwait(false); + + using var document = JsonDocument.Parse(responseBody); + return TryParsePolicy(document.RootElement, out var policy) + ? policy + : throw new InvalidOperationException("Rule-based policy 'remove rule' response could not be parsed."); + } + + public Task AssignToEnvironmentGroupAsync( + Connection connection, Credential credential, Guid policyId, Guid environmentGroupId, + IReadOnlyList? overrides, CancellationToken ct) + => AssignCoreAsync( + connection, credential, + $"governance/ruleBasedPolicies/{policyId}/environmentGroups/{environmentGroupId}/assignments?api-version={ApiVersion}", + overrides, ct); + + public Task AssignToEnvironmentAsync( + Connection connection, Credential credential, Guid policyId, Guid environmentId, + IReadOnlyList? overrides, CancellationToken ct) + => AssignCoreAsync( + connection, credential, + $"governance/ruleBasedPolicies/{policyId}/environments/{environmentId}/assignments?api-version={ApiVersion}", + overrides, ct); + + private async Task AssignCoreAsync( + Connection connection, Credential credential, string relativePath, + IReadOnlyList? overrides, CancellationToken ct) + { + var requestBody = new + { + assignmentOverrides = (overrides ?? Array.Empty()) + .Select(o => new + { + behaviorType = o.BehaviorType.ToString(), + resourceId = o.ResourceId, + resourceType = o.ResourceType.ToString(), + }) + .ToArray(), + }; + + var responseBody = await SendForBodyAsync( + connection, credential, HttpMethod.Post, BuildUri(connection, relativePath), requestBody, ct) + .ConfigureAwait(false); + + using var document = JsonDocument.Parse(responseBody); + return TryParseAssignment(document.RootElement, out var assignment) + ? assignment + : throw new InvalidOperationException("Rule-based policy assignment response could not be parsed."); + } + + public async Task> ListAssignmentsAsync( + Connection connection, Credential credential, + Guid? policyId, Guid? environmentGroupId, Guid? environmentId, CancellationToken ct) + { + var filterCount = (policyId.HasValue ? 1 : 0) + (environmentGroupId.HasValue ? 1 : 0) + (environmentId.HasValue ? 1 : 0); + if (filterCount > 1) + { + throw new ArgumentException( + "Specify at most one of policyId, environmentGroupId, or environmentId when listing policy assignments."); + } + + var relativePath = policyId.HasValue + ? $"governance/ruleBasedPolicies/{policyId}/assignments?includeRuleSetCounts=true&api-version={ApiVersion}" + : environmentGroupId.HasValue + ? $"governance/ruleBasedPolicies/environmentGroups/{environmentGroupId}/assignments?includeRuleSetCounts=true&api-version={ApiVersion}" + : environmentId.HasValue + ? $"governance/ruleBasedPolicies/environments/{environmentId}/assignments?includeRuleSetCounts=true&api-version={ApiVersion}" + : $"governance/ruleBasedPolicies/assignments?includeRuleSetCounts=true&api-version={ApiVersion}"; + + var initialRequestUri = BuildUri(connection, relativePath); + + return await ODataPagingSupport.FetchAllPagesAsync( + initialRequestUri, + (requestUri, pageCt) => SendForBodyAsync(connection, credential, HttpMethod.Get, requestUri, jsonBody: null, pageCt), + item => TryParseAssignment(item, out var assignment) ? assignment : null, + "Rule-based policy assignment payload did not contain a 'value' array.", + ct).ConfigureAwait(false); + } + + private async Task SendForBodyAsync( + Connection connection, Credential credential, HttpMethod method, Uri requestUri, object? jsonBody, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + + var token = await _tokens.AcquireForResourceAsync(connection, credential, PowerPlatformApiAudience, ct) + .ConfigureAwait(false); + + using var http = _httpFactory.Create(); + using var request = new HttpRequestMessage(method, requestUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (jsonBody is not null) + { + request.Content = new StringContent(JsonSerializer.Serialize(jsonBody), Encoding.UTF8, "application/json"); + } + + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseContentRead, ct) + .ConfigureAwait(false); + var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Rule-based policy request failed ({(int)response.StatusCode} {response.ReasonPhrase}) against '{requestUri}': {PowerPlatformRbacClient.Truncate(responseBody, 500)}"); + } + + return responseBody; + } + + private static Uri BuildUri(Connection connection, string relativePath) + => new(GetBaseUri(connection.Cloud ?? CloudInstance.Public), relativePath); + + private static Uri GetBaseUri(CloudInstance cloud) => cloud switch + { + CloudInstance.Public or CloudInstance.Gcc => new Uri("https://api.powerplatform.com/"), + _ => throw new NotSupportedException( + $"Rule-based policies are not wired for cloud '{cloud}' in this release."), + }; + + private static object? ToWireRuleSet(PowerPlatformPolicyRuleSet ruleSet) => new + { + id = ruleSet.Id, + version = ruleSet.Version, + inputs = string.IsNullOrWhiteSpace(ruleSet.InputsJson) + ? (JsonElement?)null + : JsonSerializer.Deserialize(ruleSet.InputsJson), + }; + + private static bool TryParsePolicy(JsonElement item, out PowerPlatformPolicy policy) + { + policy = null!; + + if (!TryReadGuid(item, "id", out var id) || !TryReadString(item, "name", out var name)) + return false; + + var ruleSets = new List(); + if (item.TryGetProperty("ruleSets", out var ruleSetsElement) && ruleSetsElement.ValueKind == JsonValueKind.Array) + { + foreach (var ruleSetItem in ruleSetsElement.EnumerateArray()) + { + if (TryParseRuleSet(ruleSetItem, out var ruleSet)) + ruleSets.Add(ruleSet); + } + } + + policy = new PowerPlatformPolicy( + id, + name, + TryReadOptionalString(item, "tenantId"), + TryReadOptionalDateTimeOffset(item, "lastModified"), + item.TryGetProperty("ruleSetCount", out var countElement) && countElement.TryGetInt32(out var count) ? count : ruleSets.Count, + ruleSets); + return true; + } + + private static bool TryParseRuleSet(JsonElement item, out PowerPlatformPolicyRuleSet ruleSet) + { + ruleSet = null!; + + if (!TryReadString(item, "id", out var id)) + return false; + + var version = TryReadOptionalString(item, "version") ?? "1.0"; + var inputsJson = item.TryGetProperty("inputs", out var inputs) ? inputs.GetRawText() : "{}"; + + ruleSet = new PowerPlatformPolicyRuleSet(id, version, inputsJson); + return true; + } + + private static bool TryParseAssignment(JsonElement item, out PowerPlatformPolicyAssignment assignment) + { + assignment = null!; + + if (!TryReadGuid(item, "policyId", out var policyId) || !TryReadGuid(item, "resourceId", out var resourceId)) + return false; + + var resourceType = TryReadString(item, "resourceType", out var resourceTypeRaw) + && Enum.TryParse(resourceTypeRaw, ignoreCase: true, out var parsedType) + ? parsedType + : PowerPlatformPolicyAssignmentResourceType.NotSpecified; + + assignment = new PowerPlatformPolicyAssignment( + policyId, + resourceId, + resourceType, + item.TryGetProperty("ruleSetCount", out var countElement) && countElement.TryGetInt32(out var count) ? count : 0, + TryReadOptionalString(item, "tenantId")); + return true; + } + + private static bool TryReadGuid(JsonElement element, string propertyName, out Guid value) + { + value = default; + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + return false; + + return Guid.TryParse(property.GetString(), out value); + } + + private static bool TryReadString(JsonElement element, string propertyName, out string value) + { + value = string.Empty; + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + return false; + + var raw = property.GetString(); + if (string.IsNullOrWhiteSpace(raw)) + return false; + + value = raw.Trim(); + return true; + } + + private static string? TryReadOptionalString(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString()?.Trim() + : null; + + private static DateTimeOffset? TryReadOptionalDateTimeOffset(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse(property.GetString(), out var value) + ? value + : null; +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Graph/GraphODataFilterSupport.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Graph/GraphODataFilterSupport.cs index 90c53302..ba7fb9b0 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Graph/GraphODataFilterSupport.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Graph/GraphODataFilterSupport.cs @@ -2,7 +2,7 @@ namespace TALXIS.CLI.Platform.PowerPlatform.Control.Graph; /// /// Shared Microsoft Graph OData $filter construction helpers used by -/// every txc tenant command that resolves a user or application by a +/// every txc security command that resolves a user or application by a /// caller-supplied GUID-or-friendly-name identifier (e.g. --user, /// --service-principal). /// diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Graph/MicrosoftGraphClient.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Graph/MicrosoftGraphClient.cs index 30314054..b5b85d51 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Graph/MicrosoftGraphClient.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Graph/MicrosoftGraphClient.cs @@ -12,10 +12,10 @@ public sealed record GraphUser(Guid Id, string? DisplayName, string? UserPrincip /// /// Small authenticated client for read-only Microsoft Graph directory lookups -/// used by txc tenant commands. This client deliberately supports only +/// used by txc security commands. This client deliberately supports only /// the GET endpoints required by the feature: service principals and users. /// Entra groups are intentionally never looked up through this client - see -/// the remarks on 's group resolution for why. +/// the remarks on 's group resolution for why. /// public sealed class MicrosoftGraphClient { diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformRbac/PowerPlatformRbacClient.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformRbac/PowerPlatformRbacClient.cs index 8c02428c..9d121f87 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformRbac/PowerPlatformRbacClient.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerPlatformRbac/PowerPlatformRbacClient.cs @@ -59,18 +59,44 @@ internal async Task> ListTenantRo Credential credential, CancellationToken ct) { - var scope = BuildTenantScope(connection); var initialRequestUri = BuildUri( connection, - $"authorization/roleAssignments?api-version={ApiVersion}&scope={Uri.EscapeDataString(scope)}"); + $"authorization/roleAssignments?api-version={ApiVersion}&scope={Uri.EscapeDataString(BuildTenantScope(connection))}"); + return await ListRoleAssignmentsCoreAsync(connection, credential, initialRequestUri, ct).ConfigureAwait(false); + } - return await ODataPagingSupport.FetchAllPagesAsync( + /// + /// Lists role assignments for an environment group via the dedicated + /// authorization/environmentGroups/{id}/roleAssignments route. + /// Unlike the tenant-level endpoint, environment-group role assignments + /// are NOT reachable through the generic authorization/roleAssignments + /// ?scope= query filter - Microsoft exposes a dedicated path-routed + /// resource for this scope instead (confirmed via official REST API + /// reference docs, see PowerPlatformEnvironmentGroupRoleClient remarks). + /// + internal async Task> ListEnvironmentGroupRoleAssignmentsAsync( + Connection connection, + Credential credential, + Guid environmentGroupId, + CancellationToken ct) + { + var initialRequestUri = BuildUri( + connection, + $"authorization/environmentGroups/{environmentGroupId}/roleAssignments?api-version={ApiVersion}"); + return await ListRoleAssignmentsCoreAsync(connection, credential, initialRequestUri, ct).ConfigureAwait(false); + } + + private async Task> ListRoleAssignmentsCoreAsync( + Connection connection, + Credential credential, + Uri initialRequestUri, + CancellationToken ct) + => await ODataPagingSupport.FetchAllPagesAsync( initialRequestUri, (requestUri, pageCt) => SendForBodyAsync(connection, credential, HttpMethod.Get, requestUri, jsonBody: null, pageCt), item => TryParseRoleAssignment(item, out var assignment) ? assignment : null, "Power Platform RBAC role assignment payload did not contain a 'value' array.", ct).ConfigureAwait(false); - } internal async Task AddTenantRoleAssignmentAsync( Connection connection, @@ -80,6 +106,8 @@ internal async Task> ListTenantRo Guid roleDefinitionId, CancellationToken ct) { + // The generic tenant-level endpoint requires the target scope in the + // request body (confirmed via official RBAC role-assignment tutorial). var body = new { principalObjectId, @@ -88,13 +116,51 @@ internal async Task> ListTenantRo scope = BuildTenantScope(connection), }; - var responseBody = await SendForBodyAsync( + return await AddRoleAssignmentCoreAsync( + connection, credential, BuildUri(connection, $"authorization/roleAssignments?api-version={ApiVersion}"), body, ct) + .ConfigureAwait(false); + } + + /// + /// Adds a role assignment for an environment group via the dedicated + /// authorization/environmentGroups/{id}/roleAssignments route. + /// Unlike the tenant-level request body, no scope field is sent - + /// the scope is implicit from the environment group id in the URL path + /// (confirmed via official REST API reference docs). + /// + internal async Task AddEnvironmentGroupRoleAssignmentAsync( + Connection connection, + Credential credential, + Guid environmentGroupId, + PowerPlatformPrincipalType principalType, + Guid principalObjectId, + Guid roleDefinitionId, + CancellationToken ct) + { + var body = new + { + principalObjectId, + principalType = principalType.ToString(), + roleDefinitionId, + }; + + return await AddRoleAssignmentCoreAsync( connection, credential, - HttpMethod.Post, - BuildUri(connection, $"authorization/roleAssignments?api-version={ApiVersion}"), + BuildUri(connection, $"authorization/environmentGroups/{environmentGroupId}/roleAssignments?api-version={ApiVersion}"), body, ct).ConfigureAwait(false); + } + + private async Task AddRoleAssignmentCoreAsync( + Connection connection, + Credential credential, + Uri requestUri, + object body, + CancellationToken ct) + { + var responseBody = await SendForBodyAsync(connection, credential, HttpMethod.Post, requestUri, body, ct) + .ConfigureAwait(false); if (string.IsNullOrWhiteSpace(responseBody)) return null; @@ -120,6 +186,29 @@ await SendForBodyAsync( ct).ConfigureAwait(false); } + /// + /// Removes a role assignment from an environment group via the dedicated + /// authorization/environmentGroups/{id}/roleAssignments/{roleAssignmentId} + /// route (confirmed via official REST API reference docs). + /// + internal async Task RemoveEnvironmentGroupRoleAssignmentAsync( + Connection connection, + Credential credential, + Guid environmentGroupId, + string roleAssignmentId, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(roleAssignmentId); + + await SendForBodyAsync( + connection, + credential, + HttpMethod.Delete, + BuildUri(connection, $"authorization/environmentGroups/{environmentGroupId}/roleAssignments/{roleAssignmentId}?api-version={ApiVersion}"), + jsonBody: null, + ct).ConfigureAwait(false); + } + private async Task SendForBodyAsync( Connection connection, Credential credential, diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/TenantRoleResolver.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/SecurityRoleResolver.cs similarity index 95% rename from src/TALXIS.CLI.Platform.PowerPlatform.Control/TenantRoleResolver.cs rename to src/TALXIS.CLI.Platform.PowerPlatform.Control/SecurityRoleResolver.cs index fcb0d016..88aa8640 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/TenantRoleResolver.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/SecurityRoleResolver.cs @@ -70,16 +70,16 @@ public TenantPrincipalAmbiguousException(PowerPlatformPrincipalType principalTyp /// /// Resolves human-facing tenant role inputs (principal identifiers and role /// values) into the concrete strategy/action required to manage assignments. -/// Future txc tenant commands should depend on this resolver instead of +/// Future txc security commands should depend on this resolver instead of /// talking to the PP-RBAC or legacy BAP clients directly. /// -public sealed class TenantRoleResolver +public sealed class SecurityRoleResolver { private readonly MicrosoftGraphClient _graph; private readonly PowerPlatformRbacRoleStrategy _rbacStrategy; private readonly IReadOnlyList _strategies; - public TenantRoleResolver( + public SecurityRoleResolver( MicrosoftGraphClient graph, PowerPlatformRbacRoleStrategy rbacStrategy, BapAdminApplicationRoleStrategy bapStrategy) @@ -180,7 +180,14 @@ private IPowerPlatformRoleAssignmentStrategy ResolveStrategy(PowerPlatformPrinci return strategy; } - internal async Task ResolvePrincipalAsync( + /// + /// Resolves a human-facing principal identifier (display name, UPN, app id, + /// or object id) into a concrete . + /// Public so other RBAC-scoped command surfaces beyond txc security - e.g. + /// txc governance environment-group role - can reuse the same Microsoft + /// Graph-backed resolution logic without duplicating it (DRY). + /// + public async Task ResolvePrincipalAsync( Connection connection, Credential credential, PowerPlatformPrincipalType principalType, diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Strategies/BapAdminApplicationRoleStrategy.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Strategies/BapAdminApplicationRoleStrategy.cs index d8528c27..7732756a 100644 --- a/src/TALXIS.CLI.Platform.PowerPlatform.Control/Strategies/BapAdminApplicationRoleStrategy.cs +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/Strategies/BapAdminApplicationRoleStrategy.cs @@ -15,7 +15,7 @@ namespace TALXIS.CLI.Platform.PowerPlatform.Control.Strategies; /// Platform. Unlike every other tenant role, this is not a Power Platform RBAC role /// assignment - is always /// for assignments produced by this strategy, so callers can tell it -/// apart from real RBAC assignments in tenant service-principal role list output. +/// apart from real RBAC assignments in security service-principal role list output. /// public sealed class BapAdminApplicationRoleStrategy : IPowerPlatformRoleAssignmentStrategy { diff --git a/src/TALXIS.CLI/TALXIS.CLI.csproj b/src/TALXIS.CLI/TALXIS.CLI.csproj index 92123bf0..20d8898e 100644 --- a/src/TALXIS.CLI/TALXIS.CLI.csproj +++ b/src/TALXIS.CLI/TALXIS.CLI.csproj @@ -14,7 +14,8 @@ - + + diff --git a/src/TALXIS.CLI/TxcCliCommand.cs b/src/TALXIS.CLI/TxcCliCommand.cs index cf0e51e5..8fd034b7 100644 --- a/src/TALXIS.CLI/TxcCliCommand.cs +++ b/src/TALXIS.CLI/TxcCliCommand.cs @@ -4,7 +4,7 @@ namespace TALXIS.CLI; [CliCommand( Description = "Tool for automating development loops in Power Platform", - Children = new[] { typeof(TALXIS.CLI.Features.Data.DataCliCommand), typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand), typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand), typeof(TALXIS.CLI.Features.Config.ConfigCliCommand), typeof(TALXIS.CLI.Features.Docs.DocsCliCommand), typeof(TALXIS.CLI.Component.ComponentCliCommand), typeof(TALXIS.CLI.Features.Tenant.TenantCliCommand) }, + Children = new[] { typeof(TALXIS.CLI.Features.Data.DataCliCommand), typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand), typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand), typeof(TALXIS.CLI.Features.Config.ConfigCliCommand), typeof(TALXIS.CLI.Features.Docs.DocsCliCommand), typeof(TALXIS.CLI.Component.ComponentCliCommand), typeof(TALXIS.CLI.Features.Security.SecurityCliCommand), typeof(TALXIS.CLI.Features.Governance.GovernanceCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class TxcCliCommand diff --git a/tests/TALXIS.CLI.Tests/Architecture/CommandConventionTests.cs b/tests/TALXIS.CLI.Tests/Architecture/CommandConventionTests.cs index 5837d1dd..0d593469 100644 --- a/tests/TALXIS.CLI.Tests/Architecture/CommandConventionTests.cs +++ b/tests/TALXIS.CLI.Tests/Architecture/CommandConventionTests.cs @@ -26,7 +26,7 @@ public class CommandConventionTests typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand).Assembly, typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand).Assembly, typeof(TALXIS.CLI.Features.Data.DataCliCommand).Assembly, - typeof(TALXIS.CLI.Features.Tenant.TenantCliCommand).Assembly, + typeof(TALXIS.CLI.Features.Security.SecurityCliCommand).Assembly, typeof(TALXIS.CLI.TxcCliCommand).Assembly, }; diff --git a/tests/TALXIS.CLI.Tests/Architecture/LayeringTests.cs b/tests/TALXIS.CLI.Tests/Architecture/LayeringTests.cs index 9d16d834..8fb7ecd6 100644 --- a/tests/TALXIS.CLI.Tests/Architecture/LayeringTests.cs +++ b/tests/TALXIS.CLI.Tests/Architecture/LayeringTests.cs @@ -67,7 +67,7 @@ public void DestructiveCommands_WithYesFlag_MustHaveDestructiveAnnotation() typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand).Assembly, typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand).Assembly, typeof(TALXIS.CLI.Features.Data.DataCliCommand).Assembly, - typeof(TALXIS.CLI.Features.Tenant.TenantCliCommand).Assembly, + typeof(TALXIS.CLI.Features.Security.SecurityCliCommand).Assembly, }; var violations = commandAssemblies diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/TenantRoleResolverTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/SecurityRoleResolverTests.cs similarity index 97% rename from tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/TenantRoleResolverTests.cs rename to tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/SecurityRoleResolverTests.cs index 9cdb4c17..4e46fda1 100644 --- a/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/TenantRoleResolverTests.cs +++ b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/SecurityRoleResolverTests.cs @@ -9,7 +9,7 @@ namespace TALXIS.CLI.Tests.Config.Providers.PowerPlatform; -public sealed class TenantRoleResolverTests +public sealed class SecurityRoleResolverTests { [Fact] public async Task AddAssignmentAsync_AdminApplication_ForUser_ThrowsValidationError() @@ -203,7 +203,7 @@ public async Task ListAssignmentsAsync_GroupByObjectId_ResolvesWithoutAnyGraphCa { // Groups are never resolved through Microsoft Graph (that would require the // "Group.Read.All" permission, which this CLI intentionally never requests - - // see TenantRoleResolver.ResolveGroup for the rationale). A valid GUID should + // see SecurityRoleResolver.ResolveGroup for the rationale). A valid GUID should // flow straight through to the RBAC calls with zero Graph HTTP traffic. var handlers = new Queue>([ _ => JsonResponse("{\"value\":[]}"), @@ -307,14 +307,14 @@ await Assert.ThrowsAsync(() => sut.GetTenantRoleAsync(TestConnection(), TestCredential(), "Tenant Reader", CancellationToken.None)); } - private static TenantRoleResolver CreateResolver(Queue> handlers) + private static SecurityRoleResolver CreateResolver(Queue> handlers) { var http = new FakeHttpClientFactoryWrapper(handlers); var tokens = new FakeAccessTokenService(); var graph = new MicrosoftGraphClient(tokens, http); var rbac = new PowerPlatformRbacRoleStrategy(new PowerPlatformRbacClient(tokens, http)); var bap = new BapAdminApplicationRoleStrategy(new BapAdminApiClient(tokens, http)); - return new TenantRoleResolver(graph, rbac, bap); + return new SecurityRoleResolver(graph, rbac, bap); } private static Connection TestConnection() => new() diff --git a/tests/TALXIS.CLI.Tests/Governance/EnvironmentGroup/EnvironmentGroupCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Governance/EnvironmentGroup/EnvironmentGroupCliCommandTests.cs new file mode 100644 index 00000000..0411a052 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Governance/EnvironmentGroup/EnvironmentGroupCliCommandTests.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using TALXIS.CLI.Core; +using TALXIS.CLI.Features.Governance.EnvironmentGroup; +using Xunit; + +namespace TALXIS.CLI.Tests.Governance.EnvironmentGroup; + +[Collection("TxcServicesSerial")] +public sealed class EnvironmentGroupCliCommandTests +{ + [Fact] + public async Task List_ReturnsAllSeededGroups() + { + using var host = new GovernanceCommandTestHost(); + host.Client.Add("Production groups"); + host.Client.Add("Sandboxes"); + + var output = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(output)) + { + exit = await new EnvironmentGroupListCliCommand { Format = "json" }.RunAsync(); + } + + Assert.Equal(0, exit); + using var document = JsonDocument.Parse(output.ToString()); + Assert.Equal(2, document.RootElement.GetArrayLength()); + } + + [Fact] + public async Task Create_ReturnsCreatedGroupId() + { + using var host = new GovernanceCommandTestHost(); + + var output = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(output)) + { + exit = await new EnvironmentGroupCreateCliCommand + { + Format = "json", + DisplayName = "Finance environments", + Description = "All Finance department environments.", + }.RunAsync(); + } + + Assert.Equal(0, exit); + using var document = JsonDocument.Parse(output.ToString()); + Assert.Equal("created", document.RootElement.GetProperty("status").GetString()); + Assert.True(Guid.TryParse(document.RootElement.GetProperty("id").GetString(), out _)); + } + + [Fact] + public async Task Get_ByDisplayName_ResolvesUniqueMatch() + { + using var host = new GovernanceCommandTestHost(); + var group = host.Client.Add("Finance environments"); + + var output = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(output)) + { + exit = await new EnvironmentGroupGetCliCommand { Format = "json", EnvironmentGroup = "Finance environments" }.RunAsync(); + } + + Assert.Equal(0, exit); + using var document = JsonDocument.Parse(output.ToString()); + Assert.Equal(group.Id.ToString(), document.RootElement.GetProperty("id").GetString()); + } + + [Fact] + public async Task Get_ByDisplayName_AmbiguousMatch_ReturnsValidationError() + { + using var host = new GovernanceCommandTestHost(); + host.Client.Add("Duplicate"); + host.Client.Add("Duplicate"); + + var exit = await new EnvironmentGroupGetCliCommand { Format = "json", EnvironmentGroup = "Duplicate" }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task Get_UnknownId_ReturnsValidationError() + { + using var host = new GovernanceCommandTestHost(); + + var exit = await new EnvironmentGroupGetCliCommand { Format = "json", EnvironmentGroup = Guid.NewGuid().ToString() }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task Update_ChangesOnlySuppliedFields() + { + using var host = new GovernanceCommandTestHost(); + var group = host.Client.Add("Old name", "Old description"); + + var exit = await new EnvironmentGroupUpdateCliCommand + { + Format = "json", + EnvironmentGroup = group.Id.ToString(), + DisplayName = "New name", + }.RunAsync(); + + Assert.Equal(0, exit); + var updated = await host.Client.GetAsync(null!, null!, group.Id, CancellationToken.None); + Assert.Equal("New name", updated!.DisplayName); + Assert.Equal("Old description", updated.Description); + } + + [Fact] + public async Task Update_NoFieldsSupplied_ReturnsValidationError() + { + using var host = new GovernanceCommandTestHost(); + var group = host.Client.Add("Name"); + + var exit = await new EnvironmentGroupUpdateCliCommand { Format = "json", EnvironmentGroup = group.Id.ToString() }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task Delete_RemovesGroup() + { + using var host = new GovernanceCommandTestHost(); + var group = host.Client.Add("Deletable"); + + var exit = await new EnvironmentGroupDeleteCliCommand { Format = "json", Yes = true, EnvironmentGroup = group.Id.ToString() }.RunAsync(); + + Assert.Equal(0, exit); + Assert.Contains(group.Id, host.Client.Deleted); + } + + [Fact] + public async Task Delete_WhenGroupHasMembers_ReturnsActionableError() + { + var client = new GovernanceCommandTestHost.FakeEnvironmentGroupClient(); + using var host = new GovernanceCommandTestHost(client); + var group = client.Add("Has members", environmentIds: [Guid.NewGuid()]); + client.DeleteException = new InvalidOperationException("EnvironmentsInEnvironmentGroup: group has member environments."); + + var exit = await new EnvironmentGroupDeleteCliCommand { Format = "json", Yes = true, EnvironmentGroup = group.Id.ToString() }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task EnvironmentAdd_CallsClientWithResolvedGroupId() + { + using var host = new GovernanceCommandTestHost(); + var group = host.Client.Add("Target group"); + var environmentId = Guid.NewGuid(); + + var exit = await new EnvironmentGroupEnvironmentAddCliCommand + { + Format = "json", + EnvironmentGroup = group.DisplayName, + Environment = environmentId, + }.RunAsync(); + + Assert.Equal(0, exit); + Assert.Contains((group.Id, environmentId), host.Client.AddedEnvironments); + } + + [Fact] + public async Task EnvironmentRemove_CallsClientWithResolvedGroupId() + { + using var host = new GovernanceCommandTestHost(); + var environmentId = Guid.NewGuid(); + var group = host.Client.Add("Target group", environmentIds: [environmentId]); + + var exit = await new EnvironmentGroupEnvironmentRemoveCliCommand + { + Format = "json", + EnvironmentGroup = group.Id.ToString(), + Environment = environmentId, + }.RunAsync(); + + Assert.Equal(0, exit); + Assert.Contains((group.Id, environmentId), host.Client.RemovedEnvironments); + } +} diff --git a/tests/TALXIS.CLI.Tests/Governance/EnvironmentGroup/EnvironmentGroupRoleCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Governance/EnvironmentGroup/EnvironmentGroupRoleCliCommandTests.cs new file mode 100644 index 00000000..15e22e9e --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Governance/EnvironmentGroup/EnvironmentGroupRoleCliCommandTests.cs @@ -0,0 +1,193 @@ +using System.Text.Json; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Features.Governance.EnvironmentGroup; +using Xunit; + +namespace TALXIS.CLI.Tests.Governance.EnvironmentGroup; + +[Collection("TxcServicesSerial")] +public sealed class EnvironmentGroupRoleCliCommandTests +{ + [Fact] + public async Task List_ReturnsSeededAssignments() + { + var handlers = new Queue>(); + using var host = new EnvironmentGroupRoleCommandTestHost(handlers); + var group = host.GroupClient.Add("Target group"); + host.RoleClient.Seed(group.Id, new PowerPlatformEnvironmentGroupRoleAssignment( + "ra-1", group.Id, PowerPlatformPrincipalType.Group, Guid.NewGuid(), Guid.NewGuid(), DateTimeOffset.UtcNow, null)); + + var output = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(output)) + { + exit = await new EnvironmentGroupRoleListCliCommand { Format = "json", EnvironmentGroup = group.Id.ToString() }.RunAsync(); + } + + Assert.Equal(0, exit); + using var document = JsonDocument.Parse(output.ToString()); + Assert.Equal(1, document.RootElement.GetArrayLength()); + } + + [Fact] + public async Task Add_ForGroupPrincipal_ResolvesRoleAndCallsClient() + { + var ownerRoleId = Guid.NewGuid(); + var readerRoleId = Guid.NewGuid(); + var handlers = new Queue>(); + handlers.Enqueue(_ => EnvironmentGroupRoleCommandTestHost.JsonResponse( + EnvironmentGroupRoleCommandTestHost.RoleDefinitionsPayload(ownerRoleId, readerRoleId))); + + using var host = new EnvironmentGroupRoleCommandTestHost(handlers); + var group = host.GroupClient.Add("Target group"); + var principalObjectId = Guid.NewGuid(); + + var exit = await new EnvironmentGroupRoleAddCliCommand + { + Format = "json", + EnvironmentGroup = group.Id.ToString(), + PrincipalType = PowerPlatformPrincipalType.Group, + Principal = principalObjectId.ToString(), + Role = "Owner", + }.RunAsync(); + + Assert.Equal(0, exit); + var assignments = await host.RoleClient.ListAsync(null!, null!, group.Id, CancellationToken.None); + Assert.Single(assignments); + Assert.Equal(ownerRoleId, assignments[0].RoleDefinitionId); + Assert.Equal(principalObjectId, assignments[0].PrincipalObjectId); + } + + [Fact] + public async Task Add_ForUserPrincipal_ResolvesPrincipalViaGraph() + { + var userId = Guid.NewGuid(); + var ownerRoleId = Guid.NewGuid(); + var readerRoleId = Guid.NewGuid(); + var handlers = new Queue>(); + handlers.Enqueue(_ => EnvironmentGroupRoleCommandTestHost.JsonResponse( + EnvironmentGroupRoleCommandTestHost.UserPayload(userId, "jdoe@contoso.com", "Jane Doe"))); + handlers.Enqueue(_ => EnvironmentGroupRoleCommandTestHost.JsonResponse( + EnvironmentGroupRoleCommandTestHost.RoleDefinitionsPayload(ownerRoleId, readerRoleId))); + + using var host = new EnvironmentGroupRoleCommandTestHost(handlers); + var group = host.GroupClient.Add("Target group"); + + var exit = await new EnvironmentGroupRoleAddCliCommand + { + Format = "json", + EnvironmentGroup = group.Id.ToString(), + PrincipalType = PowerPlatformPrincipalType.User, + Principal = "jdoe@contoso.com", + Role = "Reader", + }.RunAsync(); + + Assert.Equal(0, exit); + var assignments = await host.RoleClient.ListAsync(null!, null!, group.Id, CancellationToken.None); + Assert.Single(assignments); + Assert.Equal(userId, assignments[0].PrincipalObjectId); + Assert.Equal(readerRoleId, assignments[0].RoleDefinitionId); + } + + [Fact] + public async Task Add_UnknownRole_ReturnsValidationError() + { + var handlers = new Queue>(); + handlers.Enqueue(_ => EnvironmentGroupRoleCommandTestHost.JsonResponse( + EnvironmentGroupRoleCommandTestHost.RoleDefinitionsPayload(Guid.NewGuid(), Guid.NewGuid()))); + + using var host = new EnvironmentGroupRoleCommandTestHost(handlers); + var group = host.GroupClient.Add("Target group"); + + var exit = await new EnvironmentGroupRoleAddCliCommand + { + Format = "json", + EnvironmentGroup = group.Id.ToString(), + PrincipalType = PowerPlatformPrincipalType.Group, + Principal = Guid.NewGuid().ToString(), + Role = "DoesNotExist", + }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task Add_AlreadyAssigned_IsIdempotentAndDoesNotDuplicate() + { + var ownerRoleId = Guid.NewGuid(); + var readerRoleId = Guid.NewGuid(); + var principalObjectId = Guid.NewGuid(); + var handlers = new Queue>(); + handlers.Enqueue(_ => EnvironmentGroupRoleCommandTestHost.JsonResponse( + EnvironmentGroupRoleCommandTestHost.RoleDefinitionsPayload(ownerRoleId, readerRoleId))); + + using var host = new EnvironmentGroupRoleCommandTestHost(handlers); + var group = host.GroupClient.Add("Target group"); + host.RoleClient.Seed(group.Id, new PowerPlatformEnvironmentGroupRoleAssignment( + "ra-existing", group.Id, PowerPlatformPrincipalType.Group, principalObjectId, ownerRoleId, DateTimeOffset.UtcNow, null)); + + var exit = await new EnvironmentGroupRoleAddCliCommand + { + Format = "json", + EnvironmentGroup = group.Id.ToString(), + PrincipalType = PowerPlatformPrincipalType.Group, + Principal = principalObjectId.ToString(), + Role = "Owner", + }.RunAsync(); + + Assert.Equal(0, exit); + var assignments = await host.RoleClient.ListAsync(null!, null!, group.Id, CancellationToken.None); + Assert.Single(assignments); + } + + [Fact] + public async Task Remove_DeletesMatchingAssignment() + { + var ownerRoleId = Guid.NewGuid(); + var readerRoleId = Guid.NewGuid(); + var principalObjectId = Guid.NewGuid(); + var handlers = new Queue>(); + handlers.Enqueue(_ => EnvironmentGroupRoleCommandTestHost.JsonResponse( + EnvironmentGroupRoleCommandTestHost.RoleDefinitionsPayload(ownerRoleId, readerRoleId))); + + using var host = new EnvironmentGroupRoleCommandTestHost(handlers); + var group = host.GroupClient.Add("Target group"); + host.RoleClient.Seed(group.Id, new PowerPlatformEnvironmentGroupRoleAssignment( + "ra-existing", group.Id, PowerPlatformPrincipalType.Group, principalObjectId, ownerRoleId, DateTimeOffset.UtcNow, null)); + + var exit = await new EnvironmentGroupRoleRemoveCliCommand + { + Format = "json", + Yes = true, + EnvironmentGroup = group.Id.ToString(), + PrincipalType = PowerPlatformPrincipalType.Group, + Principal = principalObjectId.ToString(), + Role = "Owner", + }.RunAsync(); + + Assert.Equal(0, exit); + Assert.Contains((group.Id, "ra-existing"), host.RoleClient.Removed); + var remaining = await host.RoleClient.ListAsync(null!, null!, group.Id, CancellationToken.None); + Assert.Empty(remaining); + } + + [Fact] + public async Task Remove_UnknownEnvironmentGroup_ReturnsValidationError() + { + var handlers = new Queue>(); + using var host = new EnvironmentGroupRoleCommandTestHost(handlers); + + var exit = await new EnvironmentGroupRoleRemoveCliCommand + { + Format = "json", + Yes = true, + EnvironmentGroup = Guid.NewGuid().ToString(), + PrincipalType = PowerPlatformPrincipalType.Group, + Principal = Guid.NewGuid().ToString(), + Role = "Owner", + }.RunAsync(); + + Assert.Equal(2, exit); + } +} diff --git a/tests/TALXIS.CLI.Tests/Governance/EnvironmentGroup/EnvironmentGroupRoleCommandTestHost.cs b/tests/TALXIS.CLI.Tests/Governance/EnvironmentGroup/EnvironmentGroupRoleCommandTestHost.cs new file mode 100644 index 00000000..03ebde36 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Governance/EnvironmentGroup/EnvironmentGroupRoleCommandTestHost.cs @@ -0,0 +1,175 @@ +using Microsoft.Extensions.DependencyInjection; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control; +using TALXIS.CLI.Platform.PowerPlatform.Control.Bap; +using TALXIS.CLI.Platform.PowerPlatform.Control.Graph; +using TALXIS.CLI.Platform.PowerPlatform.Control.PowerPlatformRbac; +using TALXIS.CLI.Platform.PowerPlatform.Control.Strategies; + +namespace TALXIS.CLI.Tests.Governance.EnvironmentGroup; + +/// +/// Test host for txc governance environment-group role command +/// tests. Combines an in-memory fake +/// and (no HTTP +/// involved) with real / +/// instances backed by a queued fake HTTP transport (mirrors +/// SecurityCommandTestHost) - since command support genuinely reuses +/// that Graph-backed principal/role resolution code. +/// +internal sealed class EnvironmentGroupRoleCommandTestHost : IDisposable +{ + private readonly ServiceProvider _provider; + + public EnvironmentGroupRoleCommandTestHost(Queue> handlers) + { + GroupClient = new GovernanceCommandTestHost.FakeEnvironmentGroupClient(); + RoleClient = new FakeEnvironmentGroupRoleClient(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new FixedResolver(TestContext())); + services.AddSingleton(GroupClient); + services.AddSingleton(RoleClient); + services.AddSingleton(_ => CreateRbacClient(handlers)); + services.AddSingleton(_ => CreateResolver(handlers)); + + _provider = services.BuildServiceProvider(); + TxcServices.Initialize(_provider); + } + + public GovernanceCommandTestHost.FakeEnvironmentGroupClient GroupClient { get; } + + public FakeEnvironmentGroupRoleClient RoleClient { get; } + + public void Dispose() + { + TxcServices.Reset(); + _provider.Dispose(); + } + + public static HttpResponseMessage JsonResponse(string json) + => new(System.Net.HttpStatusCode.OK) { Content = new StringContent(json) }; + + public static string RoleDefinitionsPayload(Guid ownerRoleId, Guid readerRoleId) => + $$""" + { + "value": [ + { "roleDefinitionId": "{{ownerRoleId}}", "roleDefinitionName": "Owner", "assignableScopes": ["/tenants/*", "/environmentGroups/*"] }, + { "roleDefinitionId": "{{readerRoleId}}", "roleDefinitionName": "Reader", "assignableScopes": ["/tenants/*", "/environmentGroups/*"] } + ] + } + """; + + public static string UserPayload(Guid userId, string upn, string displayName) => + $$""" + { + "value": [ + { "id": "{{userId}}", "userPrincipalName": "{{upn}}", "displayName": "{{displayName}}" } + ] + } + """; + + private static ResolvedProfileContext TestContext() => new( + new Profile { Id = "test", ConnectionRef = "conn", CredentialRef = "cred" }, + new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + Cloud = CloudInstance.Public, + TenantId = "tenant-id", + EnvironmentType = EnvironmentType.Sandbox, + }, + new Credential { Id = "cred", Kind = CredentialKind.InteractiveBrowser }, + ResolutionSource.CommandLine); + + private static PowerPlatformRbacClient CreateRbacClient(Queue> handlers) + => new(new FakeAccessTokenService(), new FakeHttpClientFactoryWrapper(handlers)); + + private static SecurityRoleResolver CreateResolver(Queue> handlers) + { + var http = new FakeHttpClientFactoryWrapper(handlers); + var tokens = new FakeAccessTokenService(); + var graph = new MicrosoftGraphClient(tokens, http); + var rbac = new PowerPlatformRbacRoleStrategy(new PowerPlatformRbacClient(tokens, http)); + var bap = new BapAdminApplicationRoleStrategy(new BapAdminApiClient(tokens, http)); + return new SecurityRoleResolver(graph, rbac, bap); + } + + private sealed class FixedResolver(ResolvedProfileContext context) : IConfigurationResolver + { + public Task ResolveAsync(string? profileName, CancellationToken ct) => Task.FromResult(context); + } + + private sealed class FakeAccessTokenService : IAccessTokenService + { + public Task AcquireForResourceAsync(Connection connection, Credential credential, Uri resourceUri, CancellationToken ct) + => Task.FromResult("token"); + } + + private sealed class FakeHttpClientFactoryWrapper(Queue> handlers) : IHttpClientFactoryWrapper + { + private readonly Queue> _handlers = handlers; + + public HttpClient Create() => new(new FakeHttpMessageHandler(_handlers)); + } + + private sealed class FakeHttpMessageHandler(Queue> handlers) : HttpMessageHandler + { + private readonly Queue> _handlers = handlers; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_handlers.Count == 0) + throw new InvalidOperationException("No HTTP handler configured for this request."); + + return Task.FromResult(_handlers.Dequeue()(request)); + } + } + + /// + /// In-memory fake for environment-group role assignments, keyed by + /// environment group id. Mutation calls are recorded for assertions. + /// + internal sealed class FakeEnvironmentGroupRoleClient : IPowerPlatformEnvironmentGroupRoleClient + { + private readonly Dictionary> _assignments = new(); + + public List<(Guid GroupId, string RoleAssignmentId)> Removed { get; } = new(); + + public void Seed(Guid environmentGroupId, PowerPlatformEnvironmentGroupRoleAssignment assignment) + { + if (!_assignments.TryGetValue(environmentGroupId, out var list)) + _assignments[environmentGroupId] = list = new(); + + list.Add(assignment); + } + + public Task> ListAsync( + Connection connection, Credential credential, Guid environmentGroupId, CancellationToken ct) + => Task.FromResult>( + _assignments.TryGetValue(environmentGroupId, out var list) ? list.ToList() : Array.Empty()); + + public Task AddAsync( + Connection connection, Credential credential, Guid environmentGroupId, + PowerPlatformPrincipalType principalType, Guid principalObjectId, Guid roleDefinitionId, CancellationToken ct) + { + var assignment = new PowerPlatformEnvironmentGroupRoleAssignment( + Guid.NewGuid().ToString(), environmentGroupId, principalType, principalObjectId, roleDefinitionId, DateTimeOffset.UtcNow, null); + Seed(environmentGroupId, assignment); + return Task.FromResult(assignment); + } + + public Task RemoveAsync(Connection connection, Credential credential, Guid environmentGroupId, string roleAssignmentId, CancellationToken ct) + { + Removed.Add((environmentGroupId, roleAssignmentId)); + if (_assignments.TryGetValue(environmentGroupId, out var list)) + list.RemoveAll(a => a.RoleAssignmentId == roleAssignmentId); + + return Task.CompletedTask; + } + } +} diff --git a/tests/TALXIS.CLI.Tests/Governance/GovernanceCommandTestHost.cs b/tests/TALXIS.CLI.Tests/Governance/GovernanceCommandTestHost.cs new file mode 100644 index 00000000..be1c33bf --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Governance/GovernanceCommandTestHost.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Tests.Governance; + +/// +/// Shared test host for txc governance environment-group command +/// tests. Registers a fixed profile context and an in-memory fake +/// so tests exercise the +/// full CLI command pipeline (argument binding, resolution, output +/// formatting) without any real HTTP calls. +/// +internal sealed class GovernanceCommandTestHost : IDisposable +{ + private readonly ServiceProvider _provider; + + public GovernanceCommandTestHost(FakeEnvironmentGroupClient? client = null) + { + Client = client ?? new FakeEnvironmentGroupClient(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new FixedResolver(TestContext())); + services.AddSingleton(Client); + + _provider = services.BuildServiceProvider(); + TxcServices.Initialize(_provider); + } + + public FakeEnvironmentGroupClient Client { get; } + + public void Dispose() + { + TxcServices.Reset(); + _provider.Dispose(); + } + + private static ResolvedProfileContext TestContext() => new( + new Profile { Id = "test", ConnectionRef = "conn", CredentialRef = "cred" }, + new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + Cloud = CloudInstance.Public, + TenantId = "tenant-id", + EnvironmentType = EnvironmentType.Sandbox, + }, + new Credential { Id = "cred", Kind = CredentialKind.InteractiveBrowser }, + ResolutionSource.CommandLine); + + private sealed class FixedResolver(ResolvedProfileContext context) : IConfigurationResolver + { + public Task ResolveAsync(string? profileName, CancellationToken ct) => Task.FromResult(context); + } + + /// + /// In-memory fake implementing every environment-group operation against + /// a simple dictionary, so tests can assert on call arguments and seed + /// pre-existing groups without any HTTP mocking. + /// + internal sealed class FakeEnvironmentGroupClient : IPowerPlatformEnvironmentGroupClient + { + private readonly Dictionary _groups = new(); + + public List<(Guid GroupId, Guid EnvironmentId)> AddedEnvironments { get; } = new(); + public List<(Guid GroupId, Guid EnvironmentId)> RemovedEnvironments { get; } = new(); + public List Deleted { get; } = new(); + + /// When set, delete calls throw this exception instead of succeeding (used to simulate 409 conflicts). + public Exception? DeleteException { get; set; } + + public PowerPlatformEnvironmentGroup Add(string displayName, string? description = null, IReadOnlyList? environmentIds = null) + { + var group = new PowerPlatformEnvironmentGroup( + Guid.NewGuid(), displayName, description, DateTimeOffset.UtcNow, null, null, environmentIds ?? Array.Empty()); + _groups[group.Id] = group; + return group; + } + + public Task> ListAsync(Connection connection, Credential credential, CancellationToken ct) + => Task.FromResult>(_groups.Values.ToList()); + + public Task GetAsync(Connection connection, Credential credential, Guid environmentGroupId, CancellationToken ct) + => Task.FromResult(_groups.GetValueOrDefault(environmentGroupId)); + + public Task CreateAsync(Connection connection, Credential credential, PowerPlatformEnvironmentGroupCreateOptions options, CancellationToken ct) + { + var group = new PowerPlatformEnvironmentGroup(Guid.NewGuid(), options.DisplayName, options.Description, DateTimeOffset.UtcNow, null, null, Array.Empty()); + _groups[group.Id] = group; + return Task.FromResult(group); + } + + public Task UpdateAsync(Connection connection, Credential credential, Guid environmentGroupId, PowerPlatformEnvironmentGroupUpdateOptions options, CancellationToken ct) + { + var existing = _groups[environmentGroupId]; + var updated = existing with + { + DisplayName = options.DisplayName ?? existing.DisplayName, + Description = options.Description ?? existing.Description, + }; + _groups[environmentGroupId] = updated; + return Task.FromResult(updated); + } + + public Task DeleteAsync(Connection connection, Credential credential, Guid environmentGroupId, CancellationToken ct) + { + if (DeleteException is not null) + throw DeleteException; + + _groups.Remove(environmentGroupId); + Deleted.Add(environmentGroupId); + return Task.CompletedTask; + } + + public Task AddEnvironmentAsync(Connection connection, Credential credential, Guid environmentGroupId, Guid environmentId, CancellationToken ct) + { + AddedEnvironments.Add((environmentGroupId, environmentId)); + var existing = _groups[environmentGroupId]; + _groups[environmentGroupId] = existing with { EnvironmentIds = existing.EnvironmentIds.Append(environmentId).ToList() }; + return Task.CompletedTask; + } + + public Task RemoveEnvironmentAsync(Connection connection, Credential credential, Guid environmentGroupId, Guid environmentId, CancellationToken ct) + { + RemovedEnvironments.Add((environmentGroupId, environmentId)); + var existing = _groups[environmentGroupId]; + _groups[environmentGroupId] = existing with { EnvironmentIds = existing.EnvironmentIds.Where(id => id != environmentId).ToList() }; + return Task.CompletedTask; + } + } +} diff --git a/tests/TALXIS.CLI.Tests/Governance/PolicyRule/PolicyRuleCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Governance/PolicyRule/PolicyRuleCliCommandTests.cs new file mode 100644 index 00000000..a818c060 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Governance/PolicyRule/PolicyRuleCliCommandTests.cs @@ -0,0 +1,258 @@ +using System.Text.Json; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Features.Governance.PolicyRule; +using Xunit; + +namespace TALXIS.CLI.Tests.Governance.PolicyRule; + +[Collection("TxcServicesSerial")] +public sealed class PolicyRuleCliCommandTests +{ + [Fact] + public async Task List_ReturnsAllSeededPolicies() + { + using var host = new PolicyRuleCommandTestHost(); + host.Client.Add("Block risky connectors"); + host.Client.Add("Finance connector policy"); + + var output = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(output)) + { + exit = await new PolicyRuleListCliCommand { Format = "json" }.RunAsync(); + } + + Assert.Equal(0, exit); + using var document = JsonDocument.Parse(output.ToString()); + Assert.Equal(2, document.RootElement.GetArrayLength()); + } + + [Fact] + public async Task Create_WithAllowConnector_BuildsConnectorManagementRuleSet() + { + using var host = new PolicyRuleCommandTestHost(); + + var output = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(output)) + { + exit = await new PolicyRuleCreateCliCommand + { + Format = "json", + Name = "Finance connector policy", + AllowConnector = new[] { "shared_office365", "shared_sql=ExecuteProcedure,GetRows" }, + }.RunAsync(); + } + + Assert.Equal(0, exit); + using var document = JsonDocument.Parse(output.ToString()); + Assert.Equal("created", document.RootElement.GetProperty("status").GetString()); + + var policy = Assert.Single(host.Client.ListAsync(null!, null!, default).Result); + var ruleSet = Assert.Single(policy.RuleSets); + Assert.Equal(PowerPlatformPolicyRuleSet.ConnectorManagementRuleSetId, ruleSet.Id); + + var inputs = PowerPlatformAdvancedConnectorPolicyInputs.FromInputsJson(ruleSet.InputsJson); + Assert.Equal(2, inputs.AllowedConnectorList.Count); + Assert.Equal(PowerPlatformAllowedConnectorRule.AllAllowedMode, inputs.AllowedConnectorList[0].AllowedActionsMode); + Assert.Equal(PowerPlatformAllowedConnectorRule.SomeAllowedMode, inputs.AllowedConnectorList[1].AllowedActionsMode); + Assert.Equal(new[] { "ExecuteProcedure", "GetRows" }, inputs.AllowedConnectorList[1].AllowedActions); + } + + [Fact] + public async Task Create_WithBothAllowConnectorAndInputsJson_ReturnsValidationError() + { + using var host = new PolicyRuleCommandTestHost(); + + var exit = await new PolicyRuleCreateCliCommand + { + Format = "json", + Name = "Bad policy", + AllowConnector = new[] { "shared_office365" }, + RuleSetInputsJson = "{}", + }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task Update_ByName_RenamesAndAddsRuleSet() + { + using var host = new PolicyRuleCommandTestHost(); + var policy = host.Client.Add("Old name"); + + var exit = await new PolicyRuleUpdateCliCommand + { + Format = "json", + Policy = "Old name", + Name = "New name", + AllowConnector = new[] { "shared_office365" }, + }.RunAsync(); + + Assert.Equal(0, exit); + var updated = (await host.Client.GetAsync(null!, null!, policy.Id, default))!; + Assert.Equal("New name", updated.Name); + Assert.Single(updated.RuleSets); + } + + [Fact] + public async Task Update_WithNoFields_ReturnsValidationError() + { + using var host = new PolicyRuleCommandTestHost(); + var policy = host.Client.Add("Policy"); + + var exit = await new PolicyRuleUpdateCliCommand { Format = "json", Policy = policy.Id.ToString() }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task RemoveRule_RemovesTargetedRuleSetOnly() + { + using var host = new PolicyRuleCommandTestHost(); + var ruleSet = new PowerPlatformPolicyRuleSet(PowerPlatformPolicyRuleSet.ConnectorManagementRuleSetId, "1", "{}"); + var policy = host.Client.Add("Policy", new[] { ruleSet }); + + var exit = await new PolicyRuleRemoveRuleCliCommand { Format = "json", Policy = policy.Id.ToString() }.RunAsync(); + + Assert.Equal(0, exit); + var updated = (await host.Client.GetAsync(null!, null!, policy.Id, default))!; + Assert.Empty(updated.RuleSets); + Assert.Contains((policy.Id, PowerPlatformPolicyRuleSet.ConnectorManagementRuleSetId), host.Client.RemovedRuleSets); + } + + [Fact] + public async Task Assign_ToEnvironmentGroup_RecordsOverrides() + { + using var host = new PolicyRuleCommandTestHost(); + var policy = host.Client.Add("Policy"); + var groupId = Guid.NewGuid(); + var excludedEnv = Guid.NewGuid(); + + var exit = await new PolicyRuleAssignCliCommand + { + Format = "json", + Policy = policy.Id.ToString(), + EnvironmentGroup = groupId, + ExcludeEnvironment = new[] { excludedEnv }, + }.RunAsync(); + + Assert.Equal(0, exit); + var call = Assert.Single(host.Client.GroupAssignments); + Assert.Equal((policy.Id, groupId), (call.PolicyId, call.GroupId)); + var overrideEntry = Assert.Single(call.Overrides!); + Assert.Equal(PowerPlatformPolicyBehaviorType.Exclude, overrideEntry.BehaviorType); + Assert.Equal(excludedEnv, overrideEntry.ResourceId); + } + + [Fact] + public async Task Assign_ToEnvironment_Succeeds() + { + using var host = new PolicyRuleCommandTestHost(); + var policy = host.Client.Add("Policy"); + var environmentId = Guid.NewGuid(); + + var exit = await new PolicyRuleAssignCliCommand + { + Format = "json", + Policy = policy.Id.ToString(), + Environment = environmentId, + }.RunAsync(); + + Assert.Equal(0, exit); + Assert.Single(host.Client.EnvironmentAssignments); + } + + [Fact] + public async Task Assign_WithNeitherScope_ReturnsValidationError() + { + using var host = new PolicyRuleCommandTestHost(); + var policy = host.Client.Add("Policy"); + + var exit = await new PolicyRuleAssignCliCommand { Format = "json", Policy = policy.Id.ToString() }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task Assign_WithBothScopes_ReturnsValidationError() + { + using var host = new PolicyRuleCommandTestHost(); + var policy = host.Client.Add("Policy"); + + var exit = await new PolicyRuleAssignCliCommand + { + Format = "json", + Policy = policy.Id.ToString(), + EnvironmentGroup = Guid.NewGuid(), + Environment = Guid.NewGuid(), + }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task Assign_ExcludeEnvironmentWithEnvironmentScope_ReturnsValidationError() + { + using var host = new PolicyRuleCommandTestHost(); + var policy = host.Client.Add("Policy"); + + var exit = await new PolicyRuleAssignCliCommand + { + Format = "json", + Policy = policy.Id.ToString(), + Environment = Guid.NewGuid(), + ExcludeEnvironment = new[] { Guid.NewGuid() }, + }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task AssignmentList_FiltersByPolicy() + { + using var host = new PolicyRuleCommandTestHost(); + var policyA = host.Client.Add("Policy A"); + var policyB = host.Client.Add("Policy B"); + await host.Client.AssignToEnvironmentAsync(null!, null!, policyA.Id, Guid.NewGuid(), null, default); + await host.Client.AssignToEnvironmentAsync(null!, null!, policyB.Id, Guid.NewGuid(), null, default); + + var output = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(output)) + { + exit = await new PolicyRuleAssignmentListCliCommand { Format = "json", Policy = policyA.Id }.RunAsync(); + } + + Assert.Equal(0, exit); + using var document = JsonDocument.Parse(output.ToString()); + Assert.Equal(1, document.RootElement.GetArrayLength()); + Assert.Equal(policyA.Id.ToString(), document.RootElement[0].GetProperty("policyId").GetString()); + } + + [Fact] + public async Task AssignmentList_WithMultipleFilters_ReturnsValidationError() + { + using var host = new PolicyRuleCommandTestHost(); + + var exit = await new PolicyRuleAssignmentListCliCommand + { + Format = "json", + Policy = Guid.NewGuid(), + Environment = Guid.NewGuid(), + }.RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task Get_UnknownPolicy_ReturnsValidationError() + { + using var host = new PolicyRuleCommandTestHost(); + + var exit = await new PolicyRuleGetCliCommand { Format = "json", Policy = Guid.NewGuid().ToString() }.RunAsync(); + + Assert.Equal(2, exit); + } +} diff --git a/tests/TALXIS.CLI.Tests/Governance/PolicyRule/PolicyRuleCommandTestHost.cs b/tests/TALXIS.CLI.Tests/Governance/PolicyRule/PolicyRuleCommandTestHost.cs new file mode 100644 index 00000000..a03080ca --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Governance/PolicyRule/PolicyRuleCommandTestHost.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.PowerPlatform; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Tests.Governance.PolicyRule; + +/// +/// Shared test host for txc governance policy-rule command tests. +/// Registers a fixed profile context and an in-memory fake +/// so tests exercise the full +/// CLI command pipeline (argument binding, resolution, output formatting) +/// without any real HTTP calls. +/// +internal sealed class PolicyRuleCommandTestHost : IDisposable +{ + private readonly ServiceProvider _provider; + + public PolicyRuleCommandTestHost(FakePolicyRuleClient? client = null) + { + Client = client ?? new FakePolicyRuleClient(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new FixedResolver(TestContext())); + services.AddSingleton(Client); + + _provider = services.BuildServiceProvider(); + TxcServices.Initialize(_provider); + } + + public FakePolicyRuleClient Client { get; } + + public void Dispose() + { + TxcServices.Reset(); + _provider.Dispose(); + } + + private static ResolvedProfileContext TestContext() => new( + new Profile { Id = "test", ConnectionRef = "conn", CredentialRef = "cred" }, + new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + Cloud = CloudInstance.Public, + TenantId = "tenant-id", + EnvironmentType = EnvironmentType.Sandbox, + }, + new Credential { Id = "cred", Kind = CredentialKind.InteractiveBrowser }, + ResolutionSource.CommandLine); + + private sealed class FixedResolver(ResolvedProfileContext context) : IConfigurationResolver + { + public Task ResolveAsync(string? profileName, CancellationToken ct) => Task.FromResult(context); + } + + /// + /// In-memory fake implementing every rule-based-policy operation + /// against simple dictionaries/lists, so tests can assert on call + /// arguments and seed pre-existing policies/assignments without any + /// HTTP mocking. + /// + internal sealed class FakePolicyRuleClient : IPowerPlatformPolicyRuleClient + { + private readonly Dictionary _policies = new(); + private readonly List _assignments = new(); + + public List<(Guid PolicyId, string RuleSetId)> RemovedRuleSets { get; } = new(); + public List<(Guid PolicyId, Guid GroupId, IReadOnlyList? Overrides)> GroupAssignments { get; } = new(); + public List<(Guid PolicyId, Guid EnvironmentId, IReadOnlyList? Overrides)> EnvironmentAssignments { get; } = new(); + + public PowerPlatformPolicy Add(string name, IReadOnlyList? ruleSets = null) + { + var sets = ruleSets ?? Array.Empty(); + var policy = new PowerPlatformPolicy(Guid.NewGuid(), name, "tenant-id", DateTimeOffset.UtcNow, sets.Count, sets); + _policies[policy.Id] = policy; + return policy; + } + + public Task> ListAsync(Connection connection, Credential credential, CancellationToken ct) + => Task.FromResult>(_policies.Values.ToList()); + + public Task GetAsync(Connection connection, Credential credential, Guid policyId, CancellationToken ct) + => Task.FromResult(_policies.GetValueOrDefault(policyId)); + + public Task CreateAsync(Connection connection, Credential credential, PowerPlatformPolicyCreateOptions options, CancellationToken ct) + { + var policy = new PowerPlatformPolicy(Guid.NewGuid(), options.Name, "tenant-id", DateTimeOffset.UtcNow, options.RuleSets.Count, options.RuleSets); + _policies[policy.Id] = policy; + return Task.FromResult(policy); + } + + public Task UpdateAsync(Connection connection, Credential credential, Guid policyId, PowerPlatformPolicyPatchOptions options, CancellationToken ct) + { + var existing = _policies[policyId]; + var ruleSets = existing.RuleSets.ToList(); + + if (options.RuleSets is not null) + { + foreach (var ruleSet in options.RuleSets) + { + ruleSets.RemoveAll(r => r.Id == ruleSet.Id); + ruleSets.Add(ruleSet); + } + } + + var updated = existing with + { + Name = options.Name ?? existing.Name, + RuleSets = ruleSets, + RuleSetCount = ruleSets.Count, + }; + _policies[policyId] = updated; + return Task.FromResult(updated); + } + + public Task RemoveRuleSetAsync(Connection connection, Credential credential, Guid policyId, string ruleSetId, CancellationToken ct) + { + RemovedRuleSets.Add((policyId, ruleSetId)); + var existing = _policies[policyId]; + var ruleSets = existing.RuleSets.Where(r => r.Id != ruleSetId).ToList(); + var updated = existing with { RuleSets = ruleSets, RuleSetCount = ruleSets.Count }; + _policies[policyId] = updated; + return Task.FromResult(updated); + } + + public Task AssignToEnvironmentGroupAsync( + Connection connection, Credential credential, Guid policyId, Guid environmentGroupId, + IReadOnlyList? overrides, CancellationToken ct) + { + GroupAssignments.Add((policyId, environmentGroupId, overrides)); + var assignment = new PowerPlatformPolicyAssignment(policyId, environmentGroupId, PowerPlatformPolicyAssignmentResourceType.EnvironmentGroup, _policies[policyId].RuleSetCount, "tenant-id"); + _assignments.Add(assignment); + return Task.FromResult(assignment); + } + + public Task AssignToEnvironmentAsync( + Connection connection, Credential credential, Guid policyId, Guid environmentId, + IReadOnlyList? overrides, CancellationToken ct) + { + EnvironmentAssignments.Add((policyId, environmentId, overrides)); + var assignment = new PowerPlatformPolicyAssignment(policyId, environmentId, PowerPlatformPolicyAssignmentResourceType.Environment, _policies[policyId].RuleSetCount, "tenant-id"); + _assignments.Add(assignment); + return Task.FromResult(assignment); + } + + public Task> ListAssignmentsAsync( + Connection connection, Credential credential, Guid? policyId, Guid? environmentGroupId, Guid? environmentId, CancellationToken ct) + { + if (new[] { policyId, environmentGroupId, environmentId }.Count(f => f is not null) > 1) + throw new ArgumentException("Specify at most one filter."); + + IEnumerable query = _assignments; + if (policyId is { } p) query = query.Where(a => a.PolicyId == p); + if (environmentGroupId is { } g) query = query.Where(a => a.ResourceId == g && a.ResourceType == PowerPlatformPolicyAssignmentResourceType.EnvironmentGroup); + if (environmentId is { } e) query = query.Where(a => a.ResourceId == e && a.ResourceType == PowerPlatformPolicyAssignmentResourceType.Environment); + + return Task.FromResult>(query.ToList()); + } + } +} diff --git a/tests/TALXIS.CLI.Tests/Tenant/Group/GroupCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/Group/GroupCliCommandTests.cs similarity index 82% rename from tests/TALXIS.CLI.Tests/Tenant/Group/GroupCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/Group/GroupCliCommandTests.cs index ad9998bf..6dde1df0 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/Group/GroupCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/Group/GroupCliCommandTests.cs @@ -1,22 +1,22 @@ using System.Text.Json; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant.Group; +using TALXIS.CLI.Features.Security.Group; using Xunit; -namespace TALXIS.CLI.Tests.Tenant.Group; +namespace TALXIS.CLI.Tests.Security.Group; [Collection("TxcServicesSerial")] public sealed class GroupCliCommandTests { // Groups are resolved by raw Entra object id only - never through Microsoft Graph - // (see TenantRoleResolver's group-resolution remarks for why). These tests confirm + // (see SecurityRoleResolver's group-resolution remarks for why). These tests confirm // no Graph/HTTP call is ever attempted for a non-GUID --group value, and that a // valid GUID flows straight through to the Power Platform RBAC calls. [Fact] public async Task RunAsync_RoleList_NonGuidGroup_ReturnsValidationErrorWithoutAnyHttpCall() { - using var host = new TenantCommandTestHost(new Queue>()); + using var host = new SecurityCommandTestHost(new Queue>()); var output = new StringWriter(); int exit; @@ -36,11 +36,11 @@ public async Task RunAsync_RoleList_NonGuidGroup_ReturnsValidationErrorWithoutAn [Fact] public async Task RunAsync_RoleRemove_ByObjectId_RemovesAssignmentWithoutGraphCall() { - using var host = new TenantCommandTestHost(new Queue>([ + using var host = new SecurityCommandTestHost(new Queue>([ request => { Assert.Contains("roleDefinitions", request.RequestUri!.ToString()); - return TenantCommandTestHost.JsonResponse(""" + return SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -53,7 +53,7 @@ public async Task RunAsync_RoleRemove_ByObjectId_RemovesAssignmentWithoutGraphCa } """); }, - _ => TenantCommandTestHost.JsonResponse(""" + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -70,7 +70,7 @@ public async Task RunAsync_RoleRemove_ByObjectId_RemovesAssignmentWithoutGraphCa { Assert.Equal(HttpMethod.Delete, request.Method); Assert.Contains("authorization/roleAssignments/assignment-1", request.RequestUri!.ToString()); - return TenantCommandTestHost.JsonResponse(string.Empty); + return SecurityCommandTestHost.JsonResponse(string.Empty); } ])); diff --git a/tests/TALXIS.CLI.Tests/Tenant/Role/RoleGetCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/Role/RoleGetCliCommandTests.cs similarity index 84% rename from tests/TALXIS.CLI.Tests/Tenant/Role/RoleGetCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/Role/RoleGetCliCommandTests.cs index 9a870935..a0d6c5bd 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/Role/RoleGetCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/Role/RoleGetCliCommandTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant.Role; +using TALXIS.CLI.Features.Security.Role; using Xunit; -namespace TALXIS.CLI.Tests.Tenant.Role; +namespace TALXIS.CLI.Tests.Security.Role; [Collection("TxcServicesSerial")] public sealed class RoleGetCliCommandTests @@ -11,8 +11,8 @@ public sealed class RoleGetCliCommandTests [Fact] public async Task RunAsync_GuidSelector_ReturnsTenantRole() { - using var host = new TenantCommandTestHost(new Queue>([ - _ => TenantCommandTestHost.JsonResponse(""" + using var host = new SecurityCommandTestHost(new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -51,8 +51,8 @@ public async Task RunAsync_GuidSelector_ReturnsTenantRole() [Fact] public async Task RunAsync_NonTenantRoleSelector_ReturnsValidationError() { - using var host = new TenantCommandTestHost(new Queue>([ - _ => TenantCommandTestHost.JsonResponse(""" + using var host = new SecurityCommandTestHost(new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { diff --git a/tests/TALXIS.CLI.Tests/Tenant/Role/RoleListCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/Role/RoleListCliCommandTests.cs similarity index 86% rename from tests/TALXIS.CLI.Tests/Tenant/Role/RoleListCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/Role/RoleListCliCommandTests.cs index eb0efdfa..882a42a8 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/Role/RoleListCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/Role/RoleListCliCommandTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant.Role; +using TALXIS.CLI.Features.Security.Role; using Xunit; -namespace TALXIS.CLI.Tests.Tenant.Role; +namespace TALXIS.CLI.Tests.Security.Role; [Collection("TxcServicesSerial")] public sealed class RoleListCliCommandTests @@ -11,8 +11,8 @@ public sealed class RoleListCliCommandTests [Fact] public async Task RunAsync_ListsOnlyTenantAssignableRoles() { - using var host = new TenantCommandTestHost(new Queue>([ - _ => TenantCommandTestHost.JsonResponse(""" + using var host = new SecurityCommandTestHost(new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -49,8 +49,8 @@ public async Task RunAsync_ListsOnlyTenantAssignableRoles() [Fact] public async Task RunAsync_FilterFurtherNarrowsTenantRoles() { - using var host = new TenantCommandTestHost(new Queue>([ - _ => TenantCommandTestHost.JsonResponse(""" + using var host = new SecurityCommandTestHost(new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { diff --git a/tests/TALXIS.CLI.Tests/Tenant/TenantCommandTestHost.cs b/tests/TALXIS.CLI.Tests/Security/SecurityCommandTestHost.cs similarity index 62% rename from tests/TALXIS.CLI.Tests/Tenant/TenantCommandTestHost.cs rename to tests/TALXIS.CLI.Tests/Security/SecurityCommandTestHost.cs index 8f58f3f7..86544bfb 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/TenantCommandTestHost.cs +++ b/tests/TALXIS.CLI.Tests/Security/SecurityCommandTestHost.cs @@ -8,26 +8,25 @@ using TALXIS.CLI.Platform.PowerPlatform.Control.PowerPlatformRbac; using TALXIS.CLI.Platform.PowerPlatform.Control.Strategies; -namespace TALXIS.CLI.Tests.Tenant; - -/// -/// Shared HTTP-mocked test host for tenant-scope CLI command tests -/// (txc tenant user/group/app/role). Registers a -/// and -/// backed by a queue of fake HTTP responses, so tests only need to supply -/// the response bodies their command under test will request, in order. -/// -internal sealed class TenantCommandTestHost : IDisposable +namespace TALXIS.CLI.Tests.Security; + +internal sealed class SecurityCommandTestHost : IDisposable { private readonly ServiceProvider _provider; - public TenantCommandTestHost(Queue> handlers) + public SecurityCommandTestHost( + Queue> handlers, + ResolvedProfileContext? context = null, + Action? configureServices = null, + FakePowerPlatformEnvironmentCatalog? environmentCatalog = null) { var services = new ServiceCollection(); services.AddLogging(); - services.AddSingleton(new FixedResolver(TestContext())); + services.AddSingleton(new FixedResolver(context ?? TestContext())); services.AddSingleton(_ => CreateGraphClient(handlers)); services.AddSingleton(_ => CreateResolver(handlers)); + services.AddSingleton(environmentCatalog ?? new FakePowerPlatformEnvironmentCatalog()); + configureServices?.Invoke(services); _provider = services.BuildServiceProvider(); TxcServices.Initialize(_provider); @@ -42,6 +41,22 @@ public void Dispose() public static HttpResponseMessage JsonResponse(string json) => new(System.Net.HttpStatusCode.OK) { Content = new StringContent(json) }; + public static ResolvedProfileContext TestContext(bool includeEnvironment = false, Guid? environmentId = null) => new( + new Profile { Id = "test", ConnectionRef = "conn", CredentialRef = "cred" }, + new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + Cloud = CloudInstance.Public, + TenantId = "tenant-id", + EnvironmentType = EnvironmentType.Sandbox, + EnvironmentId = includeEnvironment ? environmentId ?? Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") : null, + EnvironmentUrl = includeEnvironment ? "https://contoso.crm.dynamics.com/" : null, + DisplayName = includeEnvironment ? "Contoso Sandbox" : null, + }, + new Credential { Id = "cred", Kind = CredentialKind.InteractiveBrowser }, + ResolutionSource.CommandLine); + private static MicrosoftGraphClient CreateGraphClient(Queue> handlers) { var http = new FakeHttpClientFactoryWrapper(handlers); @@ -49,29 +64,16 @@ private static MicrosoftGraphClient CreateGraphClient(Queue> handlers) + private static SecurityRoleResolver CreateResolver(Queue> handlers) { var http = new FakeHttpClientFactoryWrapper(handlers); var tokens = new FakeAccessTokenService(); var graph = new MicrosoftGraphClient(tokens, http); var rbac = new PowerPlatformRbacRoleStrategy(new PowerPlatformRbacClient(tokens, http)); var bap = new BapAdminApplicationRoleStrategy(new BapAdminApiClient(tokens, http)); - return new TenantRoleResolver(graph, rbac, bap); + return new SecurityRoleResolver(graph, rbac, bap); } - private static ResolvedProfileContext TestContext() => new( - new Profile { Id = "test", ConnectionRef = "conn", CredentialRef = "cred" }, - new Connection - { - Id = "conn", - Provider = ProviderKind.Dataverse, - Cloud = CloudInstance.Public, - TenantId = "tenant-id", - EnvironmentType = EnvironmentType.Sandbox - }, - new Credential { Id = "cred", Kind = CredentialKind.InteractiveBrowser }, - ResolutionSource.CommandLine); - private sealed class FixedResolver(ResolvedProfileContext context) : IConfigurationResolver { public Task ResolveAsync(string? profileName, CancellationToken ct) => Task.FromResult(context); @@ -102,4 +104,18 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(_handlers.Dequeue()(request)); } } + + internal sealed class FakePowerPlatformEnvironmentCatalog : IPowerPlatformEnvironmentCatalog + { + private readonly Dictionary _environments = new(); + + public void Add(PowerPlatformEnvironmentSummary environment) + => _environments[environment.EnvironmentId] = environment; + + public Task> ListAsync(Connection connection, Credential credential, CancellationToken ct) + => Task.FromResult>(_environments.Values.ToList()); + + public Task TryGetByEnvironmentUrlAsync(Connection connection, Credential credential, Uri environmentUrl, CancellationToken ct) + => Task.FromResult(_environments.Values.SingleOrDefault(e => e.EnvironmentUrl.AbsoluteUri == environmentUrl.AbsoluteUri)); + } } diff --git a/tests/TALXIS.CLI.Tests/Tenant/TenantPrincipalCommandSupportTests.cs b/tests/TALXIS.CLI.Tests/Security/SecurityPrincipalCommandSupportTests.cs similarity index 83% rename from tests/TALXIS.CLI.Tests/Tenant/TenantPrincipalCommandSupportTests.cs rename to tests/TALXIS.CLI.Tests/Security/SecurityPrincipalCommandSupportTests.cs index 23f093ae..e71fb019 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/TenantPrincipalCommandSupportTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/SecurityPrincipalCommandSupportTests.cs @@ -1,24 +1,24 @@ using Microsoft.Extensions.Logging; using TALXIS.CLI.Core.Contracts.PowerPlatform; -using TALXIS.CLI.Features.Tenant; +using TALXIS.CLI.Features.Security; using TALXIS.CLI.Platform.PowerPlatform.Control; using Xunit; -namespace TALXIS.CLI.Tests.Tenant; +namespace TALXIS.CLI.Tests.Security; /// -/// Direct unit coverage for the shared -/// helper, reused by every txc tenant user/app/group/role command +/// Direct unit coverage for the shared +/// helper, reused by every txc security user/app/group/role command /// (previously duplicated per command-support class). /// -public sealed class TenantPrincipalCommandSupportTests +public sealed class SecurityPrincipalCommandSupportTests { [Fact] public void TryHandleValidationException_AmbiguousPrincipal_LogsCandidates() { var logger = new RecordingLogger(); - var handled = TenantPrincipalCommandSupport.TryHandleValidationException( + var handled = SecurityPrincipalCommandSupport.TryHandleValidationException( logger, new TenantPrincipalAmbiguousException( PowerPlatformPrincipalType.ApplicationUser, @@ -40,7 +40,7 @@ public void TryHandleValidationException_AmbiguousRole_LogsCandidates() { var logger = new RecordingLogger(); - var handled = TenantPrincipalCommandSupport.TryHandleValidationException( + var handled = SecurityPrincipalCommandSupport.TryHandleValidationException( logger, new TenantRoleAmbiguousException("Owner", ["Owner", "Owner"]), out var exitCode); @@ -55,7 +55,7 @@ public void TryHandleValidationException_ArgumentException_ReturnsValidationErro { var logger = new RecordingLogger(); - var handled = TenantPrincipalCommandSupport.TryHandleValidationException( + var handled = SecurityPrincipalCommandSupport.TryHandleValidationException( logger, new ArgumentException("Group 'not-a-guid' must be specified as an Entra object id (GUID)."), out var exitCode); @@ -70,7 +70,7 @@ public void TryHandleValidationException_InvalidOperationException_ReturnsValida { var logger = new RecordingLogger(); - var handled = TenantPrincipalCommandSupport.TryHandleValidationException( + var handled = SecurityPrincipalCommandSupport.TryHandleValidationException( logger, new InvalidOperationException("Something went wrong."), out var exitCode); @@ -84,7 +84,7 @@ public void TryHandleValidationException_UnrelatedException_ReturnsFalse() { var logger = new RecordingLogger(); - var handled = TenantPrincipalCommandSupport.TryHandleValidationException( + var handled = SecurityPrincipalCommandSupport.TryHandleValidationException( logger, new NotSupportedException("Unsupported."), out var exitCode); diff --git a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalGetCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalGetCliCommandTests.cs similarity index 87% rename from tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalGetCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalGetCliCommandTests.cs index 5a6dad49..1d0e5e6e 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalGetCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalGetCliCommandTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant.ServicePrincipal; +using TALXIS.CLI.Features.Security.ServicePrincipal; using Xunit; -namespace TALXIS.CLI.Tests.Tenant.ServicePrincipal; +namespace TALXIS.CLI.Tests.Security.ServicePrincipal; [Collection("TxcServicesSerial")] public sealed class ServicePrincipalGetCliCommandTests @@ -11,11 +11,11 @@ public sealed class ServicePrincipalGetCliCommandTests [Fact] public async Task RunAsync_ClientIdSelector_ReturnsServicePrincipal() { - using var host = new TenantCommandTestHost(new Queue>([ + using var host = new SecurityCommandTestHost(new Queue>([ request => { Assert.Contains("appId eq 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'", Uri.UnescapeDataString(request.RequestUri!.Query), StringComparison.Ordinal); - return TenantCommandTestHost.JsonResponse(""" + return SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -50,14 +50,14 @@ public async Task RunAsync_DisplayNameSelector_DoesNotSendGuidTypedIdOrAppIdClau { // Microsoft Graph rejects the entire $filter with 400 if any clause compares a // GUID-typed property (id, appId) to a non-GUID literal, even combined with "or". - using var host = new TenantCommandTestHost(new Queue>([ + using var host = new SecurityCommandTestHost(new Queue>([ request => { var query = Uri.UnescapeDataString(request.RequestUri!.Query); Assert.DoesNotContain("appId eq", query, StringComparison.Ordinal); Assert.DoesNotContain("id eq", query, StringComparison.Ordinal); Assert.Contains("displayName eq 'Contoso CLI'", query, StringComparison.Ordinal); - return TenantCommandTestHost.JsonResponse(""" + return SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -90,8 +90,8 @@ public async Task RunAsync_DisplayNameSelector_DoesNotSendGuidTypedIdOrAppIdClau [Fact] public async Task RunAsync_AmbiguousDisplayName_ReturnsValidationErrorAndCandidates() { - using var host = new TenantCommandTestHost(new Queue>([ - _ => TenantCommandTestHost.JsonResponse(""" + using var host = new SecurityCommandTestHost(new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { diff --git a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalListCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalListCliCommandTests.cs similarity index 84% rename from tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalListCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalListCliCommandTests.cs index 194d5d80..d52f2545 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalListCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalListCliCommandTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant.ServicePrincipal; +using TALXIS.CLI.Features.Security.ServicePrincipal; using Xunit; -namespace TALXIS.CLI.Tests.Tenant.ServicePrincipal; +namespace TALXIS.CLI.Tests.Security.ServicePrincipal; [Collection("TxcServicesSerial")] public sealed class ServicePrincipalListCliCommandTests @@ -11,11 +11,11 @@ public sealed class ServicePrincipalListCliCommandTests [Fact] public async Task RunAsync_FilteredList_ReturnsServicePrincipals() { - using var host = new TenantCommandTestHost(new Queue>([ + using var host = new SecurityCommandTestHost(new Queue>([ request => { Assert.Contains("$filter=startswith(displayName,'Contoso')", Uri.UnescapeDataString(request.RequestUri!.Query), StringComparison.Ordinal); - return TenantCommandTestHost.JsonResponse(""" + return SecurityCommandTestHost.JsonResponse(""" { "value": [ { diff --git a/tests/TALXIS.CLI.Tests/Environment/ServicePrincipal/ServicePrincipalRoleAddCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalRoleAddCliCommandTests.cs similarity index 61% rename from tests/TALXIS.CLI.Tests/Environment/ServicePrincipal/ServicePrincipalRoleAddCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalRoleAddCliCommandTests.cs index 45caebff..ffb7c26c 100644 --- a/tests/TALXIS.CLI.Tests/Environment/ServicePrincipal/ServicePrincipalRoleAddCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalRoleAddCliCommandTests.cs @@ -1,30 +1,22 @@ using Microsoft.Extensions.DependencyInjection; using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Features.Environment.ServicePrincipal; +using TALXIS.CLI.Features.Security.ServicePrincipal; using Xunit; -namespace TALXIS.CLI.Tests.Environment.ServicePrincipal; +namespace TALXIS.CLI.Tests.Security.ServicePrincipal; -/// -/// Regression coverage for 's idempotent -/// no-op behavior: re-running role add for a role that is already -/// assigned must report "unchanged" and must not call -/// again, matching the -/// equivalent behavior on environment user role add. -/// [Collection("TxcServicesSerial")] public sealed class ServicePrincipalRoleAddCliCommandTests { + private static readonly Guid EnvironmentId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); private static readonly Guid RoleId = Guid.Parse("22222222-2222-2222-2222-222222222222"); [Fact] public async Task RunAsync_RoleAlreadyAssigned_ReturnsUnchangedWithoutMutating() { var role = new DataverseRoleRecord(RoleId, "Owner", null, null); - using var host = new FakeServicePrincipalServiceHost(existingRoles: new[] { role }); + using var host = CreateHost(new[] { role }); var output = new StringWriter(); int exit; @@ -46,7 +38,7 @@ public async Task RunAsync_RoleAlreadyAssigned_ReturnsUnchangedWithoutMutating() [Fact] public async Task RunAsync_RoleNotYetAssigned_AddsRoleAndReportsRoleAdded() { - using var host = new FakeServicePrincipalServiceHost(existingRoles: Array.Empty()); + using var host = CreateHost(Array.Empty()); var output = new StringWriter(); int exit; @@ -65,60 +57,53 @@ public async Task RunAsync_RoleNotYetAssigned_AddsRoleAndReportsRoleAdded() Assert.Contains("\"status\": \"role-added\"", output.ToString()); } - private sealed class FakeServicePrincipalServiceHost : IDisposable + private static HostBundle CreateHost(IReadOnlyList existingRoles) { - private readonly ServiceProvider _provider; - - public FakeServicePrincipalService Service { get; } - - public FakeServicePrincipalServiceHost(IReadOnlyList existingRoles) - { - Service = new FakeServicePrincipalService(existingRoles); - - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(Service); - - _provider = services.BuildServiceProvider(); - TxcServices.Initialize(_provider); - } + var service = new FakeServicePrincipalService(existingRoles); + var catalog = new SecurityCommandTestHost.FakePowerPlatformEnvironmentCatalog(); + catalog.Add(new(EnvironmentId, "Contoso Sandbox", new Uri("https://contoso.crm.dynamics.com/"), null, null, null, TALXIS.CLI.Core.Model.EnvironmentType.Sandbox)); + var host = new SecurityCommandTestHost( + new Queue>(), + SecurityCommandTestHost.TestContext(includeEnvironment: true, environmentId: EnvironmentId), + services => services.AddSingleton(service), + catalog); + return new HostBundle(host, service); + } - public void Dispose() - { - TxcServices.Reset(); - _provider.Dispose(); - } + private sealed record HostBundle(SecurityCommandTestHost Host, FakeServicePrincipalService Service) : IDisposable + { + public void Dispose() => Host.Dispose(); } private sealed class FakeServicePrincipalService(IReadOnlyList existingRoles) : IDataverseServicePrincipalService { public bool AddRoleAsyncCalled { get; private set; } - public Task> ListAsync(string? profileName, DataverseSecurityPrincipalStateFilter filter, CancellationToken ct) + public Task> ListAsync(string? profileName, DataverseSecurityPrincipalStateFilter filter, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task GetAsync(string? profileName, string clientIdOrGuid, CancellationToken ct) + public Task GetAsync(string? profileName, string clientIdOrGuid, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task CreateAsync(string? profileName, DataverseServicePrincipalCreateOptions options, CancellationToken ct) + public Task CreateAsync(string? profileName, DataverseServicePrincipalCreateOptions options, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task UpdateEnabledStateAsync(string? profileName, string clientIdOrGuid, bool enabled, CancellationToken ct) + public Task UpdateEnabledStateAsync(string? profileName, string clientIdOrGuid, bool enabled, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task DeleteAsync(string? profileName, string clientIdOrGuid, CancellationToken ct) + public Task DeleteAsync(string? profileName, string clientIdOrGuid, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task> ListRolesAsync(string? profileName, string clientIdOrGuid, CancellationToken ct) + public Task> ListRolesAsync(string? profileName, string clientIdOrGuid, CancellationToken ct, Guid? environmentId = null) => Task.FromResult(existingRoles); - public Task AddRoleAsync(string? profileName, string clientIdOrGuid, string roleNameOrGuid, CancellationToken ct) + public Task AddRoleAsync(string? profileName, string clientIdOrGuid, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) { AddRoleAsyncCalled = true; return Task.CompletedTask; } - public Task RemoveRoleAsync(string? profileName, string clientIdOrGuid, string roleNameOrGuid, CancellationToken ct) + public Task RemoveRoleAsync(string? profileName, string clientIdOrGuid, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); } } diff --git a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalRoleListCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalRoleListCliCommandTests.cs similarity index 83% rename from tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalRoleListCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalRoleListCliCommandTests.cs index 2079b631..fe2a382b 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalRoleListCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalRoleListCliCommandTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant.ServicePrincipal; +using TALXIS.CLI.Features.Security.ServicePrincipal; using Xunit; -namespace TALXIS.CLI.Tests.Tenant.ServicePrincipal; +namespace TALXIS.CLI.Tests.Security.ServicePrincipal; [Collection("TxcServicesSerial")] public sealed class ServicePrincipalRoleListCliCommandTests @@ -11,8 +11,8 @@ public sealed class ServicePrincipalRoleListCliCommandTests [Fact] public async Task RunAsync_ReturnsTenantRoleAndAdminApplicationAssignment() { - using var host = new TenantCommandTestHost(new Queue>([ - _ => TenantCommandTestHost.JsonResponse(""" + using var host = new SecurityCommandTestHost(new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -23,7 +23,7 @@ public async Task RunAsync_ReturnsTenantRoleAndAdminApplicationAssignment() ] } """), - _ => TenantCommandTestHost.JsonResponse(""" + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -35,7 +35,7 @@ public async Task RunAsync_ReturnsTenantRoleAndAdminApplicationAssignment() ] } """), - _ => TenantCommandTestHost.JsonResponse(""" + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -48,7 +48,7 @@ public async Task RunAsync_ReturnsTenantRoleAndAdminApplicationAssignment() ] } """), - _ => TenantCommandTestHost.JsonResponse(""" + _ => SecurityCommandTestHost.JsonResponse(""" [ { "applicationId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" diff --git a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalRoleRemoveCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalRoleRemoveCliCommandTests.cs similarity index 83% rename from tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalRoleRemoveCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalRoleRemoveCliCommandTests.cs index faaaacdd..5c5d964f 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalRoleRemoveCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/ServicePrincipal/ServicePrincipalRoleRemoveCliCommandTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant.ServicePrincipal; +using TALXIS.CLI.Features.Security.ServicePrincipal; using Xunit; -namespace TALXIS.CLI.Tests.Tenant.ServicePrincipal; +namespace TALXIS.CLI.Tests.Security.ServicePrincipal; [Collection("TxcServicesSerial")] public sealed class ServicePrincipalRoleRemoveCliCommandTests @@ -11,8 +11,8 @@ public sealed class ServicePrincipalRoleRemoveCliCommandTests [Fact] public async Task RunAsync_AdminApplicationRole_RemovesAssignment() { - using var host = new TenantCommandTestHost(new Queue>([ - _ => TenantCommandTestHost.JsonResponse(""" + using var host = new SecurityCommandTestHost(new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -23,7 +23,7 @@ public async Task RunAsync_AdminApplicationRole_RemovesAssignment() ] } """), - _ => TenantCommandTestHost.JsonResponse(""" + _ => SecurityCommandTestHost.JsonResponse(""" [ { "applicationId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" diff --git a/tests/TALXIS.CLI.Tests/Environment/Team/TeamRoleAddCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/Team/TeamRoleAddCliCommandTests.cs similarity index 62% rename from tests/TALXIS.CLI.Tests/Environment/Team/TeamRoleAddCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/Team/TeamRoleAddCliCommandTests.cs index 874ba2e2..f9b3ec66 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Team/TeamRoleAddCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/Team/TeamRoleAddCliCommandTests.cs @@ -1,30 +1,22 @@ using Microsoft.Extensions.DependencyInjection; using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Features.Environment.Team; +using TALXIS.CLI.Features.Security.Team; using Xunit; -namespace TALXIS.CLI.Tests.Environment.Team; +namespace TALXIS.CLI.Tests.Security.Team; -/// -/// Regression coverage for 's idempotent -/// no-op behavior: re-running role add for a role that is already -/// assigned must report "unchanged" and must not call -/// again, matching the -/// equivalent behavior on environment user role add. -/// [Collection("TxcServicesSerial")] public sealed class TeamRoleAddCliCommandTests { + private static readonly Guid EnvironmentId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); private static readonly Guid RoleId = Guid.Parse("22222222-2222-2222-2222-222222222222"); [Fact] public async Task RunAsync_RoleAlreadyAssigned_ReturnsUnchangedWithoutMutating() { var role = new DataverseRoleRecord(RoleId, "Owner", null, null); - using var host = new FakeTeamServiceHost(existingRoles: new[] { role }); + using var host = CreateHost(new[] { role }); var output = new StringWriter(); int exit; @@ -46,7 +38,7 @@ public async Task RunAsync_RoleAlreadyAssigned_ReturnsUnchangedWithoutMutating() [Fact] public async Task RunAsync_RoleNotYetAssigned_AddsRoleAndReportsSucceeded() { - using var host = new FakeTeamServiceHost(existingRoles: Array.Empty()); + using var host = CreateHost(Array.Empty()); var output = new StringWriter(); int exit; @@ -65,66 +57,59 @@ public async Task RunAsync_RoleNotYetAssigned_AddsRoleAndReportsSucceeded() Assert.Contains("\"status\": \"succeeded\"", output.ToString()); } - private sealed class FakeTeamServiceHost : IDisposable + private static HostBundle CreateHost(IReadOnlyList existingRoles) { - private readonly ServiceProvider _provider; - - public FakeTeamService Service { get; } - - public FakeTeamServiceHost(IReadOnlyList existingRoles) - { - Service = new FakeTeamService(existingRoles); - - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(Service); - - _provider = services.BuildServiceProvider(); - TxcServices.Initialize(_provider); - } + var service = new FakeTeamService(existingRoles); + var catalog = new SecurityCommandTestHost.FakePowerPlatformEnvironmentCatalog(); + catalog.Add(new(EnvironmentId, "Contoso Sandbox", new Uri("https://contoso.crm.dynamics.com/"), null, null, null, TALXIS.CLI.Core.Model.EnvironmentType.Sandbox)); + var host = new SecurityCommandTestHost( + new Queue>(), + SecurityCommandTestHost.TestContext(includeEnvironment: true, environmentId: EnvironmentId), + services => services.AddSingleton(service), + catalog); + return new HostBundle(host, service); + } - public void Dispose() - { - TxcServices.Reset(); - _provider.Dispose(); - } + private sealed record HostBundle(SecurityCommandTestHost Host, FakeTeamService Service) : IDisposable + { + public void Dispose() => Host.Dispose(); } private sealed class FakeTeamService(IReadOnlyList existingRoles) : IDataverseTeamService { public bool AddRoleAsyncCalled { get; private set; } - public Task> ListAsync(string? profileName, CancellationToken ct) + public Task> ListAsync(string? profileName, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task GetAsync(string? profileName, string nameOrGuid, CancellationToken ct) + public Task GetAsync(string? profileName, string nameOrGuid, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task CreateAsync(string? profileName, DataverseTeamCreateOptions options, CancellationToken ct) + public Task CreateAsync(string? profileName, DataverseTeamCreateOptions options, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task DeleteAsync(string? profileName, string nameOrGuid, CancellationToken ct) + public Task DeleteAsync(string? profileName, string nameOrGuid, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task> ListMembersAsync(string? profileName, string teamIdOrName, CancellationToken ct) + public Task> ListMembersAsync(string? profileName, string teamIdOrName, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task AddMemberAsync(string? profileName, string teamIdOrName, string userIdOrUpn, CancellationToken ct) + public Task AddMemberAsync(string? profileName, string teamIdOrName, string userIdOrUpn, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task RemoveMemberAsync(string? profileName, string teamIdOrName, string userIdOrUpn, CancellationToken ct) + public Task RemoveMemberAsync(string? profileName, string teamIdOrName, string userIdOrUpn, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); - public Task> ListRolesAsync(string? profileName, string teamIdOrName, CancellationToken ct) + public Task> ListRolesAsync(string? profileName, string teamIdOrName, CancellationToken ct, Guid? environmentId = null) => Task.FromResult(existingRoles); - public Task AddRoleAsync(string? profileName, string teamIdOrName, string roleNameOrGuid, CancellationToken ct) + public Task AddRoleAsync(string? profileName, string teamIdOrName, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) { AddRoleAsyncCalled = true; return Task.CompletedTask; } - public Task RemoveRoleAsync(string? profileName, string teamIdOrName, string roleNameOrGuid, CancellationToken ct) + public Task RemoveRoleAsync(string? profileName, string teamIdOrName, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) => throw new NotImplementedException(); } } diff --git a/tests/TALXIS.CLI.Tests/Tenant/User/UserCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/User/UserCliCommandTests.cs similarity index 85% rename from tests/TALXIS.CLI.Tests/Tenant/User/UserCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/User/UserCliCommandTests.cs index b65b6138..fab47fd8 100644 --- a/tests/TALXIS.CLI.Tests/Tenant/User/UserCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/User/UserCliCommandTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant.User; +using TALXIS.CLI.Features.Security.User; using Xunit; -namespace TALXIS.CLI.Tests.Tenant.User; +namespace TALXIS.CLI.Tests.Security.User; [Collection("TxcServicesSerial")] public sealed class UserCliCommandTests @@ -11,12 +11,12 @@ public sealed class UserCliCommandTests [Fact] public async Task RunAsync_List_WithFilter_ReturnsUsers() { - using var host = new TenantCommandTestHost(new Queue>([ + using var host = new SecurityCommandTestHost(new Queue>([ request => { Assert.Equal(HttpMethod.Get, request.Method); Assert.Contains("$filter=startswith(userPrincipalName,'alice') or startswith(displayName,'alice')", Uri.UnescapeDataString(request.RequestUri!.Query)); - return TenantCommandTestHost.JsonResponse(""" + return SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -51,11 +51,11 @@ public async Task RunAsync_List_WithFilter_ReturnsUsers() [Fact] public async Task RunAsync_Get_MissingUser_ReturnsValidationError() { - using var host = new TenantCommandTestHost(new Queue>([ + using var host = new SecurityCommandTestHost(new Queue>([ request => { Assert.Contains("$filter=userPrincipalName eq 'missing@contoso.com'", Uri.UnescapeDataString(request.RequestUri!.Query)); - return TenantCommandTestHost.JsonResponse(""" + return SecurityCommandTestHost.JsonResponse(""" { "value": [] } @@ -81,12 +81,12 @@ public async Task RunAsync_Get_MissingUser_ReturnsValidationError() [Fact] public async Task RunAsync_Get_ByObjectId_IncludesGuidTypedIdClause() { - using var host = new TenantCommandTestHost(new Queue>([ + using var host = new SecurityCommandTestHost(new Queue>([ request => { var query = Uri.UnescapeDataString(request.RequestUri!.Query); Assert.Contains("$filter=id eq '11111111-1111-1111-1111-111111111111' or userPrincipalName eq '11111111-1111-1111-1111-111111111111'", query); - return TenantCommandTestHost.JsonResponse(""" + return SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -119,8 +119,8 @@ public async Task RunAsync_Get_ByObjectId_IncludesGuidTypedIdClause() [Fact] public async Task RunAsync_RoleAdd_AmbiguousRole_ReturnsValidationError() { - using var host = new TenantCommandTestHost(new Queue>([ - _ => TenantCommandTestHost.JsonResponse(""" + using var host = new SecurityCommandTestHost(new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { @@ -131,7 +131,7 @@ public async Task RunAsync_RoleAdd_AmbiguousRole_ReturnsValidationError() ] } """), - _ => TenantCommandTestHost.JsonResponse(""" + _ => SecurityCommandTestHost.JsonResponse(""" { "value": [ { diff --git a/tests/TALXIS.CLI.Tests/Environment/User/UserRoleCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Security/User/UserRoleCliCommandTests.cs similarity index 67% rename from tests/TALXIS.CLI.Tests/Environment/User/UserRoleCliCommandTests.cs rename to tests/TALXIS.CLI.Tests/Security/User/UserRoleCliCommandTests.cs index f9814b8c..f3d75993 100644 --- a/tests/TALXIS.CLI.Tests/Environment/User/UserRoleCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Security/User/UserRoleCliCommandTests.cs @@ -1,25 +1,15 @@ using Microsoft.Extensions.DependencyInjection; using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Contracts.Dataverse; -using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Core.Model; -using TALXIS.CLI.Features.Environment.User; +using TALXIS.CLI.Features.Security.User; using Xunit; -namespace TALXIS.CLI.Tests.Environment.User; +namespace TALXIS.CLI.Tests.Security.User; -/// -/// Regression coverage for the exit-code/candidate-listing behavior of -/// and -/// when the final Dataverse mutation throws -/// after resolution has -/// already succeeded (e.g. a race between resolve and mutate). Mirrors the -/// equivalent App/Team role command tests. -/// [Collection("TxcServicesSerial")] public sealed class UserRoleCliCommandTests { + private static readonly Guid EnvironmentId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); private static readonly Guid UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid RoleId = Guid.Parse("22222222-2222-2222-2222-222222222222"); @@ -34,7 +24,7 @@ public async Task RunAsync_RoleAdd_AmbiguousMatchOnMutate_ReturnsValidationError new(Guid.NewGuid(), "Owner", "Second owner role.") }; - using var host = new FakeUserServiceHost( + using var host = CreateHost( user, role, existingRoles: Array.Empty(), @@ -67,7 +57,7 @@ public async Task RunAsync_RoleRemove_AmbiguousMatchOnMutate_ReturnsValidationEr new(Guid.NewGuid(), "Owner", "Second owner role.") }; - using var host = new FakeUserServiceHost( + using var host = CreateHost( user, role, existingRoles: new[] { role }, @@ -90,30 +80,23 @@ public async Task RunAsync_RoleRemove_AmbiguousMatchOnMutate_ReturnsValidationEr Assert.Equal(string.Empty, output.ToString()); } - private sealed class FakeUserServiceHost : IDisposable + private static SecurityCommandTestHost CreateHost( + DataverseUserRecord user, + DataverseRoleRecord role, + IReadOnlyList existingRoles, + Exception mutateException) { - private readonly ServiceProvider _provider; - - public FakeUserServiceHost( - DataverseUserRecord user, - DataverseRoleRecord role, - IReadOnlyList existingRoles, - Exception mutateException) - { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(new FakeUserService(user, existingRoles, mutateException)); - services.AddSingleton(new FakeRoleService(role)); - - _provider = services.BuildServiceProvider(); - TxcServices.Initialize(_provider); - } - - public void Dispose() - { - TxcServices.Reset(); - _provider.Dispose(); - } + var catalog = new SecurityCommandTestHost.FakePowerPlatformEnvironmentCatalog(); + catalog.Add(new(EnvironmentId, "Contoso Sandbox", new Uri("https://contoso.crm.dynamics.com/"), null, null, null, TALXIS.CLI.Core.Model.EnvironmentType.Sandbox)); + return new SecurityCommandTestHost( + new Queue>(), + SecurityCommandTestHost.TestContext(includeEnvironment: true, environmentId: EnvironmentId), + services => + { + services.AddSingleton(new FakeUserService(user, existingRoles, mutateException)); + services.AddSingleton(new FakeRoleService(role)); + }, + catalog); } private sealed class FakeUserService( @@ -121,31 +104,31 @@ private sealed class FakeUserService( IReadOnlyList existingRoles, Exception mutateException) : IDataverseUserService { - public Task> ListAsync(string? profileName, DataverseSecurityPrincipalStateFilter filter, CancellationToken ct) + public Task> ListAsync(string? profileName, DataverseSecurityPrincipalStateFilter filter, CancellationToken ct, Guid? environmentId = null) => Task.FromResult>(new[] { user }); - public Task GetAsync(string? profileName, string userIdOrUpn, CancellationToken ct) + public Task GetAsync(string? profileName, string userIdOrUpn, CancellationToken ct, Guid? environmentId = null) => Task.FromResult(user); - public Task UpdateEnabledStateAsync(string? profileName, string userIdOrUpn, bool enabled, CancellationToken ct) + public Task UpdateEnabledStateAsync(string? profileName, string userIdOrUpn, bool enabled, CancellationToken ct, Guid? environmentId = null) => Task.CompletedTask; - public Task> ListRolesAsync(string? profileName, string userIdOrUpn, CancellationToken ct) + public Task> ListRolesAsync(string? profileName, string userIdOrUpn, CancellationToken ct, Guid? environmentId = null) => Task.FromResult(existingRoles); - public Task AddRoleAsync(string? profileName, string userIdOrUpn, string roleNameOrGuid, CancellationToken ct) + public Task AddRoleAsync(string? profileName, string userIdOrUpn, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) => throw mutateException; - public Task RemoveRoleAsync(string? profileName, string userIdOrUpn, string roleNameOrGuid, CancellationToken ct) + public Task RemoveRoleAsync(string? profileName, string userIdOrUpn, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) => throw mutateException; } private sealed class FakeRoleService(DataverseRoleRecord role) : IDataverseRoleService { - public Task> ListAsync(string? profileName, string? filter, CancellationToken ct) + public Task> ListAsync(string? profileName, string? filter, CancellationToken ct, Guid? environmentId = null) => Task.FromResult>(new[] { role }); - public Task GetAsync(string? profileName, string nameOrGuid, CancellationToken ct) + public Task GetAsync(string? profileName, string nameOrGuid, CancellationToken ct, Guid? environmentId = null) => Task.FromResult(role); } } diff --git a/tests/TALXIS.CLI.Tests/Security/User/UserScopeResolutionTests.cs b/tests/TALXIS.CLI.Tests/Security/User/UserScopeResolutionTests.cs new file mode 100644 index 00000000..9bc25fc2 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Security/User/UserScopeResolutionTests.cs @@ -0,0 +1,228 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Features.Security.Team; +using TALXIS.CLI.Features.Security.User; +using Xunit; + +namespace TALXIS.CLI.Tests.Security.User; + +[Collection("TxcServicesSerial")] +public sealed class UserScopeResolutionTests +{ + private static readonly Guid ActiveEnvironmentId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid ExplicitEnvironmentId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + [Fact] + public async Task UserList_ExplicitEnvironmentOverridesActiveConnection() + { + var service = new RecordingUserService(); + var catalog = new SecurityCommandTestHost.FakePowerPlatformEnvironmentCatalog(); + catalog.Add(new(ActiveEnvironmentId, "Active", new Uri("https://active.crm.dynamics.com/"), null, null, null, EnvironmentType.Sandbox)); + catalog.Add(new(ExplicitEnvironmentId, "Explicit", new Uri("https://explicit.crm.dynamics.com/"), null, null, null, EnvironmentType.Sandbox)); + + using var host = new SecurityCommandTestHost( + new Queue>(), + SecurityCommandTestHost.TestContext(includeEnvironment: true, environmentId: ActiveEnvironmentId), + services => services.AddSingleton(service), + catalog); + + var exit = await new UserListCliCommand { Environment = ExplicitEnvironmentId }.RunAsync(); + + Assert.Equal(0, exit); + Assert.Equal(ExplicitEnvironmentId, service.LastEnvironmentId); + } + + [Fact] + public async Task UserList_ActiveConnectionFallsBackToEnvironmentScope() + { + var service = new RecordingUserService(); + var catalog = new SecurityCommandTestHost.FakePowerPlatformEnvironmentCatalog(); + catalog.Add(new(ActiveEnvironmentId, "Active", new Uri("https://active.crm.dynamics.com/"), null, null, null, EnvironmentType.Sandbox)); + + using var host = new SecurityCommandTestHost( + new Queue>(), + SecurityCommandTestHost.TestContext(includeEnvironment: true, environmentId: ActiveEnvironmentId), + services => services.AddSingleton(service), + catalog); + + var exit = await new UserListCliCommand().RunAsync(); + + Assert.Equal(0, exit); + Assert.Equal(ActiveEnvironmentId, service.LastEnvironmentId); + } + + [Fact] + public async Task UserList_WithoutResolvedEnvironmentFallsBackToTenantUsers() + { + var service = new RecordingUserService(); + using var host = new SecurityCommandTestHost( + new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" + { + "value": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "userPrincipalName": "alice@contoso.com", + "displayName": "Alice Adams" + } + ] + } + """) + ]), + SecurityCommandTestHost.TestContext(includeEnvironment: false), + services => services.AddSingleton(service)); + + var output = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(output)) + { + exit = await new UserListCliCommand { Format = "json" }.RunAsync(); + } + + Assert.Equal(0, exit); + Assert.Null(service.LastEnvironmentId); + Assert.Contains("alice@contoso.com", output.ToString()); + } + + [Fact] + public async Task TeamList_WithoutResolvableEnvironment_ReturnsValidationError() + { + using var host = new SecurityCommandTestHost(new Queue>(), SecurityCommandTestHost.TestContext(includeEnvironment: false)); + var exit = await new TeamListCliCommand().RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task ServicePrincipalRoleList_WithEnvironmentScope_ReturnsTenantAndEnvironmentSections() + { + var catalog = new SecurityCommandTestHost.FakePowerPlatformEnvironmentCatalog(); + catalog.Add(new(ActiveEnvironmentId, "Active", new Uri("https://active.crm.dynamics.com/"), null, null, null, EnvironmentType.Sandbox)); + + using var host = new SecurityCommandTestHost( + new Queue>([ + _ => SecurityCommandTestHost.JsonResponse(""" + { + "value": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "appId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "displayName": "Contoso CLI" + } + ] + } + """), + _ => SecurityCommandTestHost.JsonResponse(""" + { + "value": [ + { + "roleDefinitionId": "33333333-3333-3333-3333-333333333333", + "roleDefinitionName": "Tenant Reader", + "description": "Can read tenant settings.", + "assignableScopes": ["/tenants/tenant-id"] + } + ] + } + """), + _ => SecurityCommandTestHost.JsonResponse(""" + { + "value": [ + { + "roleAssignmentId": "assign-1", + "roleDefinitionId": "33333333-3333-3333-3333-333333333333", + "scope": "/tenants/tenant-id", + "principalType": "ApplicationUser", + "principalObjectId": "11111111-1111-1111-1111-111111111111" + } + ] + } + """), + _ => SecurityCommandTestHost.JsonResponse(""" + [ + { + "applicationId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + } + ] + """) + ]), + SecurityCommandTestHost.TestContext(includeEnvironment: true, environmentId: ActiveEnvironmentId), + services => services.AddSingleton(new FakeScopedServicePrincipalService()), + catalog); + + var output = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(output)) + { + exit = await new TALXIS.CLI.Features.Security.ServicePrincipal.ServicePrincipalRoleListCliCommand + { + Format = "json", + ServicePrincipal = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + }.RunAsync(); + } + + Assert.Equal(0, exit); + var document = JsonDocument.Parse(output.ToString()); + Assert.True(document.RootElement.TryGetProperty("tenantAdminRoles", out var tenantRoles)); + Assert.True(document.RootElement.TryGetProperty("environmentSecurityRoles", out var environmentRoles)); + Assert.True(tenantRoles.GetArrayLength() >= 1); + Assert.Equal(1, environmentRoles.GetArrayLength()); + Assert.Equal("System Administrator", environmentRoles[0].GetProperty("name").GetString()); + } + + private sealed class RecordingUserService : IDataverseUserService + { + public Guid? LastEnvironmentId { get; private set; } + + public Task> ListAsync(string? profileName, DataverseSecurityPrincipalStateFilter filter, CancellationToken ct, Guid? environmentId = null) + { + LastEnvironmentId = environmentId; + return Task.FromResult>(Array.Empty()); + } + + public Task GetAsync(string? profileName, string userIdOrUpn, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task UpdateEnabledStateAsync(string? profileName, string userIdOrUpn, bool enabled, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task> ListRolesAsync(string? profileName, string userIdOrUpn, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task AddRoleAsync(string? profileName, string userIdOrUpn, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task RemoveRoleAsync(string? profileName, string userIdOrUpn, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + } + + private sealed class FakeScopedServicePrincipalService : IDataverseServicePrincipalService + { + public Task> ListAsync(string? profileName, DataverseSecurityPrincipalStateFilter filter, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task GetAsync(string? profileName, string clientIdOrGuid, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task CreateAsync(string? profileName, DataverseServicePrincipalCreateOptions options, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task UpdateEnabledStateAsync(string? profileName, string clientIdOrGuid, bool enabled, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task DeleteAsync(string? profileName, string clientIdOrGuid, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task> ListRolesAsync(string? profileName, string clientIdOrGuid, CancellationToken ct, Guid? environmentId = null) + => Task.FromResult>([ + new DataverseRoleRecord(Guid.Parse("44444444-4444-4444-4444-444444444444"), "System Administrator", null, "Root Business Unit") + ]); + + public Task AddRoleAsync(string? profileName, string clientIdOrGuid, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + + public Task RemoveRoleAsync(string? profileName, string clientIdOrGuid, string roleNameOrGuid, CancellationToken ct, Guid? environmentId = null) + => throw new NotImplementedException(); + } +} diff --git a/tests/TALXIS.CLI.Tests/TALXIS.CLI.Tests.csproj b/tests/TALXIS.CLI.Tests/TALXIS.CLI.Tests.csproj index e5217538..7b30a65b 100644 --- a/tests/TALXIS.CLI.Tests/TALXIS.CLI.Tests.csproj +++ b/tests/TALXIS.CLI.Tests/TALXIS.CLI.Tests.csproj @@ -25,7 +25,8 @@ - + + diff --git a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalRoleAddCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalRoleAddCliCommandTests.cs deleted file mode 100644 index aeb740ad..00000000 --- a/tests/TALXIS.CLI.Tests/Tenant/ServicePrincipal/ServicePrincipalRoleAddCliCommandTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Tenant.ServicePrincipal; -using Xunit; - -namespace TALXIS.CLI.Tests.Tenant.ServicePrincipal; - -[Collection("TxcServicesSerial")] -public sealed class ServicePrincipalRoleAddCliCommandTests -{ - [Fact] - public async Task RunAsync_AmbiguousRole_ReturnsValidationErrorAndCandidates() - { - using var host = new TenantCommandTestHost(new Queue>([ - _ => TenantCommandTestHost.JsonResponse(""" - { - "value": [ - { - "id": "11111111-1111-1111-1111-111111111111", - "appId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - "displayName": "Contoso CLI" - } - ] - } - """), - _ => TenantCommandTestHost.JsonResponse(""" - { - "value": [ - { - "roleDefinitionId": "33333333-3333-3333-3333-333333333333", - "roleDefinitionName": "Owner", - "description": "First owner role.", - "assignableScopes": ["/tenants/tenant-id"] - }, - { - "roleDefinitionId": "44444444-4444-4444-4444-444444444444", - "roleDefinitionName": "Owner", - "description": "Second owner role.", - "assignableScopes": ["/tenants/tenant-id"] - } - ] - } - """) - ])); - - var output = new StringWriter(); - var error = new StringWriter(); - var originalError = Console.Error; - int exit; - - try - { - Console.SetError(error); - using (OutputWriter.RedirectTo(output)) - { - exit = await new ServicePrincipalRoleAddCliCommand - { - Format = "json", - ServicePrincipal = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - Role = "Owner" - }.RunAsync(); - } - } - finally - { - Console.SetError(originalError); - } - - Assert.Equal(2, exit); - Assert.Equal(string.Empty, output.ToString()); - Assert.DoesNotContain("{", error.ToString()); - } -}