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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
17 changes: 16 additions & 1 deletion TALXIS.CLI.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
131 changes: 131 additions & 0 deletions docs/command-taxonomy.md
Original file line number Diff line number Diff line change
@@ -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 <id>]
txc security service-principal list/get/create/update/delete/role [--environment <id>]
txc security role list/get [--environment <id>]
txc security team list/get/create/update/delete/member/role --environment <id> (required)
txc security group list/get/role (Entra security groups; tenant-only)
```

**`--environment <id>` 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 <id>]` 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 <id>]` 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 <id>` 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.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public interface IDataverseRoleService
Task<IReadOnlyList<DataverseRoleRecord>> ListAsync(
string? profileName,
string? filter,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Resolves a single Dataverse role by role GUID or exact role name.
Expand All @@ -23,5 +24,6 @@ Task<IReadOnlyList<DataverseRoleRecord>> ListAsync(
Task<DataverseRoleRecord?> GetAsync(
string? profileName,
string nameOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public interface IDataverseServicePrincipalService
Task<IReadOnlyList<DataverseServicePrincipalRecord>> ListAsync(
string? profileName,
DataverseSecurityPrincipalStateFilter filter,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Resolves a single Dataverse service principal by system-user GUID or
Expand All @@ -23,7 +24,8 @@ Task<IReadOnlyList<DataverseServicePrincipalRecord>> ListAsync(
Task<DataverseServicePrincipalRecord?> GetAsync(
string? profileName,
string clientIdOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Creates a Dataverse service principal directly in the environment and
Expand All @@ -33,7 +35,8 @@ Task<IReadOnlyList<DataverseServicePrincipalRecord>> ListAsync(
Task<DataverseServicePrincipalRecord> CreateAsync(
string? profileName,
DataverseServicePrincipalCreateOptions options,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Enables or disables a Dataverse service principal resolved from a system
Expand All @@ -44,7 +47,8 @@ Task UpdateEnabledStateAsync(
string? profileName,
string clientIdOrGuid,
bool enabled,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Hard-deletes a Dataverse service principal. Dataverse only allows this
Expand All @@ -54,7 +58,8 @@ Task UpdateEnabledStateAsync(
Task DeleteAsync(
string? profileName,
string clientIdOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Lists security roles assigned to the resolved Dataverse application
Expand All @@ -64,7 +69,8 @@ Task DeleteAsync(
Task<IReadOnlyList<DataverseRoleRecord>> ListRolesAsync(
string? profileName,
string clientIdOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Assigns a Dataverse security role to the resolved service principal.
Expand All @@ -77,7 +83,8 @@ Task AddRoleAsync(
string? profileName,
string clientIdOrGuid,
string roleNameOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Removes a Dataverse security role from the resolved service principal.
Expand All @@ -90,5 +97,6 @@ Task RemoveRoleAsync(
string? profileName,
string clientIdOrGuid,
string roleNameOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);
}
30 changes: 20 additions & 10 deletions src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseTeamService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public interface IDataverseTeamService
/// </summary>
Task<IReadOnlyList<DataverseTeamRecord>> ListAsync(
string? profileName,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Resolves a single Dataverse team by team GUID or exact team name.
Expand All @@ -21,7 +22,8 @@ Task<IReadOnlyList<DataverseTeamRecord>> ListAsync(
Task<DataverseTeamRecord?> GetAsync(
string? profileName,
string nameOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Creates a Dataverse team. When no business unit is supplied, the current
Expand All @@ -31,7 +33,8 @@ Task<IReadOnlyList<DataverseTeamRecord>> ListAsync(
Task<DataverseTeamRecord> CreateAsync(
string? profileName,
DataverseTeamCreateOptions options,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Deletes a Dataverse team resolved from a GUID or exact team name.
Expand All @@ -41,7 +44,8 @@ Task<DataverseTeamRecord> CreateAsync(
Task DeleteAsync(
string? profileName,
string nameOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Lists Dataverse users that are direct members of an owner or access
Expand All @@ -52,7 +56,8 @@ Task DeleteAsync(
Task<IReadOnlyList<DataverseUserRecord>> ListMembersAsync(
string? profileName,
string teamIdOrName,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Adds a Dataverse environment user to an owner or access team. Throws a
Expand All @@ -63,7 +68,8 @@ Task AddMemberAsync(
string? profileName,
string teamIdOrName,
string userIdOrUpn,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Removes a Dataverse environment user from an owner or access team.
Expand All @@ -74,7 +80,8 @@ Task RemoveMemberAsync(
string? profileName,
string teamIdOrName,
string userIdOrUpn,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Lists security roles assigned to the resolved Dataverse team. Throws
Expand All @@ -84,7 +91,8 @@ Task RemoveMemberAsync(
Task<IReadOnlyList<DataverseRoleRecord>> ListRolesAsync(
string? profileName,
string teamIdOrName,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Assigns a Dataverse security role to the resolved team. Both the team
Expand All @@ -96,7 +104,8 @@ Task AddRoleAsync(
string? profileName,
string teamIdOrName,
string roleNameOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);

/// <summary>
/// Removes a Dataverse security role from the resolved team. Both the team
Expand All @@ -108,5 +117,6 @@ Task RemoveRoleAsync(
string? profileName,
string teamIdOrName,
string roleNameOrGuid,
CancellationToken ct);
CancellationToken ct,
Guid? environmentId = null);
}
Loading