diff --git a/schemas/plugin-registration.schema.json b/schemas/plugin-registration.schema.json new file mode 100644 index 000000000..ca1de8cc1 --- /dev/null +++ b/schemas/plugin-registration.schema.json @@ -0,0 +1,200 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/joshsmithxrm/ppds-sdk/main/schemas/plugin-registration.schema.json", + "title": "PPDS Plugin Registration Configuration", + "description": "Schema for plugin registration configuration files used by the PPDS CLI.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON schema reference for validation." + }, + "version": { + "type": "string", + "description": "Schema version for forward compatibility.", + "default": "1.0" + }, + "generatedAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the configuration was generated (Zulu time format)." + }, + "assemblies": { + "type": "array", + "description": "List of plugin assemblies in this configuration.", + "items": { + "$ref": "#/$defs/assembly" + } + } + }, + "required": ["assemblies"], + "additionalProperties": true, + "$defs": { + "assembly": { + "type": "object", + "description": "Configuration for a single plugin assembly.", + "properties": { + "name": { + "type": "string", + "description": "Assembly name (without extension)." + }, + "type": { + "type": "string", + "enum": ["Assembly", "Nuget"], + "description": "Assembly type: 'Assembly' for classic DLL, 'Nuget' for NuGet package.", + "default": "Assembly" + }, + "path": { + "type": "string", + "description": "Relative path to the assembly DLL (from the config file location). Use forward slashes for cross-platform compatibility." + }, + "packagePath": { + "type": "string", + "description": "Relative path to the NuGet package (for Nuget type only)." + }, + "solution": { + "type": "string", + "description": "Solution unique name to add components to." + }, + "allTypeNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "All plugin type names in this assembly (for orphan detection)." + }, + "types": { + "type": "array", + "description": "Plugin types with their step registrations.", + "items": { + "$ref": "#/$defs/pluginType" + } + } + }, + "required": ["name", "types"], + "additionalProperties": true + }, + "pluginType": { + "type": "object", + "description": "Configuration for a single plugin type (class).", + "properties": { + "typeName": { + "type": "string", + "description": "Fully qualified type name (namespace.classname)." + }, + "steps": { + "type": "array", + "description": "Step registrations for this plugin type.", + "items": { + "$ref": "#/$defs/step" + } + } + }, + "required": ["typeName", "steps"], + "additionalProperties": true + }, + "step": { + "type": "object", + "description": "Configuration for a single plugin step registration.", + "properties": { + "name": { + "type": "string", + "description": "Display name for the step. Auto-generated if not specified: '{TypeName}: {Message} of {Entity}'." + }, + "message": { + "type": "string", + "description": "SDK message name (Create, Update, Delete, Retrieve, etc.)." + }, + "entity": { + "type": "string", + "description": "Primary entity logical name. Use 'none' for global messages." + }, + "secondaryEntity": { + "type": "string", + "description": "Secondary entity logical name for relationship messages (Associate, etc.)." + }, + "stage": { + "type": "string", + "enum": ["PreValidation", "PreOperation", "MainOperation", "PostOperation"], + "description": "Pipeline stage. MainOperation is for Custom API plugins." + }, + "mode": { + "type": "string", + "enum": ["Synchronous", "Asynchronous"], + "description": "Execution mode.", + "default": "Synchronous" + }, + "executionOrder": { + "type": "integer", + "description": "Execution order when multiple plugins handle the same event.", + "default": 1 + }, + "filteringAttributes": { + "type": "string", + "description": "Comma-separated list of attributes that trigger this step (Update message only)." + }, + "unsecureConfiguration": { + "type": "string", + "description": "Unsecure configuration string passed to plugin constructor. Safe for source control." + }, + "deployment": { + "type": "string", + "enum": ["ServerOnly", "Offline", "Both"], + "description": "Deployment target.", + "default": "ServerOnly" + }, + "runAsUser": { + "type": "string", + "description": "User context to run the plugin as. Use 'CallingUser' (default), 'System', or a systemuser GUID." + }, + "description": { + "type": "string", + "description": "Description of what this step does." + }, + "asyncAutoDelete": { + "type": "boolean", + "description": "For async steps, whether to delete the async job on successful completion.", + "default": false + }, + "stepId": { + "type": "string", + "description": "Step identifier for associating images with specific steps on multi-step plugins." + }, + "images": { + "type": "array", + "description": "Entity images registered for this step.", + "items": { + "$ref": "#/$defs/image" + } + } + }, + "required": ["message", "entity", "stage"], + "additionalProperties": true + }, + "image": { + "type": "object", + "description": "Configuration for a plugin step image (pre-image or post-image).", + "properties": { + "name": { + "type": "string", + "description": "Name used to access the image in plugin context." + }, + "entityAlias": { + "type": "string", + "description": "Entity alias for the image. Defaults to 'name' if not specified." + }, + "imageType": { + "type": "string", + "enum": ["PreImage", "PostImage", "Both"], + "description": "Image type." + }, + "attributes": { + "type": "string", + "description": "Comma-separated list of attributes to include. Null means all attributes (not recommended for performance)." + } + }, + "required": ["name", "imageType"], + "additionalProperties": true + } + } +} diff --git a/src/PPDS.Auth/CHANGELOG.md b/src/PPDS.Auth/CHANGELOG.md index 78b31235c..fcabdf5d3 100644 --- a/src/PPDS.Auth/CHANGELOG.md +++ b/src/PPDS.Auth/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-beta.2] - 2026-01-01 + +### Fixed + +- **Cross-tenant token cache issue** - Fixed bug where `ppds env list` would return environments from wrong tenant when user had profiles for multiple tenants. Root cause was MSAL account lookup using `FirstOrDefault()` instead of filtering by tenant. Now uses `HomeAccountId` for precise account lookup with tenant filtering fallback. ([#59](https://github.com/joshsmithxrm/ppds-sdk/issues/59)) + +### Added + +- `HomeAccountId` property on `AuthProfile` and `ICredentialProvider` to track MSAL account identity across sessions + ## [1.0.0-beta.1] - 2025-12-29 ### Added @@ -31,5 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - JWT claims parsing for identity information - Targets: `net8.0`, `net10.0` -[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Auth-v1.0.0-beta.1...HEAD +[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Auth-v1.0.0-beta.2...HEAD +[1.0.0-beta.2]: https://github.com/joshsmithxrm/ppds-sdk/compare/Auth-v1.0.0-beta.1...Auth-v1.0.0-beta.2 [1.0.0-beta.1]: https://github.com/joshsmithxrm/ppds-sdk/releases/tag/Auth-v1.0.0-beta.1 diff --git a/src/PPDS.Auth/Credentials/AzureDevOpsFederatedCredentialProvider.cs b/src/PPDS.Auth/Credentials/AzureDevOpsFederatedCredentialProvider.cs index ba848b00d..5bf0a241a 100644 --- a/src/PPDS.Auth/Credentials/AzureDevOpsFederatedCredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/AzureDevOpsFederatedCredentialProvider.cs @@ -38,6 +38,9 @@ public sealed class AzureDevOpsFederatedCredentialProvider : ICredentialProvider /// public string? ObjectId => null; // Not available for federated auth without additional calls + /// + public string? HomeAccountId => null; // Federated auth doesn't use MSAL user cache + /// public string? AccessToken => _cachedToken?.Token; diff --git a/src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs b/src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs index aca2d44d0..249495fb3 100644 --- a/src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs @@ -39,6 +39,9 @@ public sealed class CertificateFileCredentialProvider : ICredentialProvider /// public string? ObjectId => null; // Service principals don't have a user OID + /// + public string? HomeAccountId => null; // Service principals don't use MSAL user cache + /// public string? AccessToken => null; // Connection string auth doesn't expose the token diff --git a/src/PPDS.Auth/Credentials/CertificateStoreCredentialProvider.cs b/src/PPDS.Auth/Credentials/CertificateStoreCredentialProvider.cs index 56e1296e7..fafa682be 100644 --- a/src/PPDS.Auth/Credentials/CertificateStoreCredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/CertificateStoreCredentialProvider.cs @@ -43,6 +43,9 @@ public sealed class CertificateStoreCredentialProvider : ICredentialProvider /// public string? ObjectId => null; // Service principals don't have a user OID + /// + public string? HomeAccountId => null; // Service principals don't use MSAL user cache + /// public string? AccessToken => null; // Connection string auth doesn't expose the token diff --git a/src/PPDS.Auth/Credentials/ClientSecretCredentialProvider.cs b/src/PPDS.Auth/Credentials/ClientSecretCredentialProvider.cs index a3ca178fb..fec41e553 100644 --- a/src/PPDS.Auth/Credentials/ClientSecretCredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/ClientSecretCredentialProvider.cs @@ -35,6 +35,9 @@ public sealed class ClientSecretCredentialProvider : ICredentialProvider /// public string? ObjectId => null; // Service principals don't have a user OID + /// + public string? HomeAccountId => null; // Service principals don't use MSAL user cache + /// public string? AccessToken => null; // Connection string auth doesn't expose the token diff --git a/src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs b/src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs index 6b5374eab..892a43a90 100644 --- a/src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs @@ -26,6 +26,7 @@ public sealed class DeviceCodeCredentialProvider : ICredentialProvider private readonly CloudEnvironment _cloud; private readonly string? _tenantId; private readonly string? _username; + private readonly string? _homeAccountId; private readonly Action? _deviceCodeCallback; private IPublicClientApplication? _msalClient; @@ -48,6 +49,9 @@ public sealed class DeviceCodeCredentialProvider : ICredentialProvider /// public string? ObjectId => _cachedResult?.UniqueId; + /// + public string? HomeAccountId => _cachedResult?.Account?.HomeAccountId?.Identifier; + /// public string? AccessToken => _cachedResult?.AccessToken; @@ -60,16 +64,19 @@ public sealed class DeviceCodeCredentialProvider : ICredentialProvider /// The cloud environment. /// Optional tenant ID (defaults to "organizations" for multi-tenant). /// Optional username for silent auth lookup. + /// Optional MSAL home account identifier for precise account lookup. /// Optional callback for displaying device code (defaults to console output). public DeviceCodeCredentialProvider( CloudEnvironment cloud = CloudEnvironment.Public, string? tenantId = null, string? username = null, + string? homeAccountId = null, Action? deviceCodeCallback = null) { _cloud = cloud; _tenantId = tenantId; _username = username; + _homeAccountId = homeAccountId; _deviceCodeCallback = deviceCodeCallback; } @@ -87,6 +94,7 @@ public static DeviceCodeCredentialProvider FromProfile( profile.Cloud, profile.TenantId, profile.Username, + profile.HomeAccountId, deviceCodeCallback); } @@ -143,19 +151,14 @@ private async Task GetTokenAsync(string environmentUrl, bool forceIntera return _cachedResult.AccessToken; } - // Try silent acquisition from MSAL cache - var accounts = await _msalClient!.GetAccountsAsync().ConfigureAwait(false); - - // Look up by username if we have one, otherwise fall back to first account - var account = !string.IsNullOrEmpty(_username) - ? accounts.FirstOrDefault(a => string.Equals(a.Username, _username, StringComparison.OrdinalIgnoreCase)) - : accounts.FirstOrDefault(); + // Try to find the correct account for silent acquisition + var account = await FindAccountAsync().ConfigureAwait(false); if (account != null) { try { - _cachedResult = await _msalClient + _cachedResult = await _msalClient! .AcquireTokenSilent(scopes, account) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); @@ -209,6 +212,12 @@ private async Task GetTokenAsync(string environmentUrl, bool forceIntera return _cachedResult.AccessToken; } + /// + /// Finds the correct cached account for this profile. + /// + private Task FindAccountAsync() + => MsalAccountHelper.FindAccountAsync(_msalClient!, _homeAccountId, _tenantId, _username); + /// /// Ensures the MSAL client is initialized with token cache. /// diff --git a/src/PPDS.Auth/Credentials/GitHubFederatedCredentialProvider.cs b/src/PPDS.Auth/Credentials/GitHubFederatedCredentialProvider.cs index 6c2b5db6d..2247c51ec 100644 --- a/src/PPDS.Auth/Credentials/GitHubFederatedCredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/GitHubFederatedCredentialProvider.cs @@ -38,6 +38,9 @@ public sealed class GitHubFederatedCredentialProvider : ICredentialProvider /// public string? ObjectId => null; // Not available for federated auth without additional calls + /// + public string? HomeAccountId => null; // Federated auth doesn't use MSAL user cache + /// public string? AccessToken => _cachedToken?.Token; diff --git a/src/PPDS.Auth/Credentials/ICredentialProvider.cs b/src/PPDS.Auth/Credentials/ICredentialProvider.cs index 2fbb87bf4..3cabb0061 100644 --- a/src/PPDS.Auth/Credentials/ICredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/ICredentialProvider.cs @@ -53,6 +53,13 @@ Task CreateServiceClientAsync( /// string? ObjectId { get; } + /// + /// Gets the MSAL home account identifier. + /// Format: {objectId}.{tenantId} - uniquely identifies the account+tenant for token cache lookup. + /// Available after successful authentication. + /// + string? HomeAccountId { get; } + /// /// Gets the access token from the last authentication. /// Available after successful authentication. Used for extracting JWT claims. diff --git a/src/PPDS.Auth/Credentials/InteractiveBrowserCredentialProvider.cs b/src/PPDS.Auth/Credentials/InteractiveBrowserCredentialProvider.cs index 747d4bae5..627b13c1f 100644 --- a/src/PPDS.Auth/Credentials/InteractiveBrowserCredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/InteractiveBrowserCredentialProvider.cs @@ -28,6 +28,7 @@ public sealed class InteractiveBrowserCredentialProvider : ICredentialProvider private readonly CloudEnvironment _cloud; private readonly string? _tenantId; private readonly string? _username; + private readonly string? _homeAccountId; private IPublicClientApplication? _msalClient; private MsalCacheHelper? _cacheHelper; @@ -49,6 +50,9 @@ public sealed class InteractiveBrowserCredentialProvider : ICredentialProvider /// public string? ObjectId => _cachedResult?.UniqueId; + /// + public string? HomeAccountId => _cachedResult?.Account?.HomeAccountId?.Identifier; + /// public string? AccessToken => _cachedResult?.AccessToken; @@ -61,14 +65,17 @@ public sealed class InteractiveBrowserCredentialProvider : ICredentialProvider /// The cloud environment. /// Optional tenant ID (defaults to "organizations" for multi-tenant). /// Optional username for silent auth lookup. + /// Optional MSAL home account identifier for precise account lookup. public InteractiveBrowserCredentialProvider( CloudEnvironment cloud = CloudEnvironment.Public, string? tenantId = null, - string? username = null) + string? username = null, + string? homeAccountId = null) { _cloud = cloud; _tenantId = tenantId; _username = username; + _homeAccountId = homeAccountId; } /// @@ -81,7 +88,8 @@ public static InteractiveBrowserCredentialProvider FromProfile(AuthProfile profi return new InteractiveBrowserCredentialProvider( profile.Cloud, profile.TenantId, - profile.Username); + profile.Username, + profile.HomeAccountId); } /// @@ -176,19 +184,14 @@ private async Task GetTokenAsync(string environmentUrl, bool forceIntera return _cachedResult.AccessToken; } - // Try silent acquisition from MSAL cache - var accounts = await _msalClient!.GetAccountsAsync().ConfigureAwait(false); - - // Look up by username if we have one, otherwise fall back to first account - var account = !string.IsNullOrEmpty(_username) - ? accounts.FirstOrDefault(a => string.Equals(a.Username, _username, StringComparison.OrdinalIgnoreCase)) - : accounts.FirstOrDefault(); + // Try to find the correct account for silent acquisition + var account = await FindAccountAsync().ConfigureAwait(false); if (account != null) { try { - _cachedResult = await _msalClient + _cachedResult = await _msalClient! .AcquireTokenSilent(scopes, account) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); @@ -225,6 +228,12 @@ private async Task GetTokenAsync(string environmentUrl, bool forceIntera return _cachedResult.AccessToken; } + /// + /// Finds the correct cached account for this profile. + /// + private Task FindAccountAsync() + => MsalAccountHelper.FindAccountAsync(_msalClient!, _homeAccountId, _tenantId, _username); + /// /// Ensures the MSAL client is initialized with token cache. /// diff --git a/src/PPDS.Auth/Credentials/ManagedIdentityCredentialProvider.cs b/src/PPDS.Auth/Credentials/ManagedIdentityCredentialProvider.cs index 2cbef3a26..fb66b7368 100644 --- a/src/PPDS.Auth/Credentials/ManagedIdentityCredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/ManagedIdentityCredentialProvider.cs @@ -40,6 +40,9 @@ public sealed class ManagedIdentityCredentialProvider : ICredentialProvider /// public string? ObjectId => null; // Managed identity doesn't expose OID + /// + public string? HomeAccountId => null; // Managed identity doesn't use MSAL user cache + /// public string? AccessToken => _cachedToken?.Token; diff --git a/src/PPDS.Auth/Credentials/MsalAccountHelper.cs b/src/PPDS.Auth/Credentials/MsalAccountHelper.cs new file mode 100644 index 000000000..f7683f187 --- /dev/null +++ b/src/PPDS.Auth/Credentials/MsalAccountHelper.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Identity.Client; + +namespace PPDS.Auth.Credentials; + +/// +/// Helper class for MSAL account lookup operations. +/// Provides consistent account selection logic across credential providers. +/// +internal static class MsalAccountHelper +{ + /// + /// Finds the correct cached account for a profile. + /// Uses HomeAccountId for precise lookup, falls back to tenant filtering, then username. + /// + /// The MSAL public client application. + /// Optional MSAL home account identifier for precise lookup. + /// Optional tenant ID for filtering. + /// Optional username for filtering. + /// The matching account, or null if no match found (forces re-authentication). + internal static async Task FindAccountAsync( + IPublicClientApplication msalClient, + string? homeAccountId, + string? tenantId, + string? username = null) + { + // Best case: we have the exact account identifier stored + if (!string.IsNullOrEmpty(homeAccountId)) + { + var account = await msalClient.GetAccountAsync(homeAccountId).ConfigureAwait(false); + if (account != null) + return account; + } + + // Fall back to filtering accounts + var accounts = await msalClient.GetAccountsAsync().ConfigureAwait(false); + var accountList = accounts.ToList(); + + if (accountList.Count == 0) + return null; + + // If we have a tenant ID, filter by it to avoid cross-tenant token usage + if (!string.IsNullOrEmpty(tenantId)) + { + var tenantAccount = accountList.FirstOrDefault(a => + string.Equals(a.HomeAccountId?.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + if (tenantAccount != null) + return tenantAccount; + } + + // Fall back to username match + if (!string.IsNullOrEmpty(username)) + { + var usernameAccount = accountList.FirstOrDefault(a => + string.Equals(a.Username, username, StringComparison.OrdinalIgnoreCase)); + if (usernameAccount != null) + return usernameAccount; + } + + // If we can't find the right account, return null to force re-authentication. + // Never silently use a random cached account - that causes cross-tenant issues. + return null; + } +} diff --git a/src/PPDS.Auth/Credentials/UsernamePasswordCredentialProvider.cs b/src/PPDS.Auth/Credentials/UsernamePasswordCredentialProvider.cs index b7606da9b..fd7e01ec6 100644 --- a/src/PPDS.Auth/Credentials/UsernamePasswordCredentialProvider.cs +++ b/src/PPDS.Auth/Credentials/UsernamePasswordCredentialProvider.cs @@ -44,6 +44,9 @@ public sealed class UsernamePasswordCredentialProvider : ICredentialProvider /// public string? ObjectId => _cachedResult?.UniqueId; + /// + public string? HomeAccountId => _cachedResult?.Account?.HomeAccountId?.Identifier; + /// public string? AccessToken => _cachedResult?.AccessToken; diff --git a/src/PPDS.Auth/Discovery/GlobalDiscoveryService.cs b/src/PPDS.Auth/Discovery/GlobalDiscoveryService.cs index 01851eca2..9a78be382 100644 --- a/src/PPDS.Auth/Discovery/GlobalDiscoveryService.cs +++ b/src/PPDS.Auth/Discovery/GlobalDiscoveryService.cs @@ -24,6 +24,8 @@ public sealed class GlobalDiscoveryService : IGlobalDiscoveryService, IDisposabl private readonly CloudEnvironment _cloud; private readonly string? _tenantId; + private readonly string? _homeAccountId; + private readonly AuthMethod? _preferredAuthMethod; private readonly Action? _deviceCodeCallback; private IPublicClientApplication? _msalClient; @@ -35,14 +37,20 @@ public sealed class GlobalDiscoveryService : IGlobalDiscoveryService, IDisposabl /// /// The cloud environment to use. /// Optional tenant ID. + /// Optional MSAL home account identifier for precise account lookup. + /// Optional preferred auth method from profile. /// Optional callback for device code display. public GlobalDiscoveryService( CloudEnvironment cloud = CloudEnvironment.Public, string? tenantId = null, + string? homeAccountId = null, + AuthMethod? preferredAuthMethod = null, Action? deviceCodeCallback = null) { _cloud = cloud; _tenantId = tenantId; + _homeAccountId = homeAccountId; + _preferredAuthMethod = preferredAuthMethod; _deviceCodeCallback = deviceCodeCallback; } @@ -59,6 +67,8 @@ public static GlobalDiscoveryService FromProfile( return new GlobalDiscoveryService( profile.Cloud, profile.TenantId, + profile.HomeAccountId, + profile.AuthMethod, deviceCodeCallback); } @@ -136,15 +146,14 @@ private Func> CreateTokenProviderFunction( { var scopes = new[] { $"{discoveryUri.GetLeftPart(UriPartial.Authority)}/.default" }; - // Try silent acquisition first - var accounts = await _msalClient!.GetAccountsAsync().ConfigureAwait(false); - var account = accounts.FirstOrDefault(); + // Try to find the correct account for silent acquisition + var account = await FindAccountAsync().ConfigureAwait(false); if (account != null) { try { - var silentResult = await _msalClient + var silentResult = await _msalClient! .AcquireTokenSilent(scopes, account) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); @@ -156,36 +165,57 @@ private Func> CreateTokenProviderFunction( } } - // Fall back to device code flow - var result = await _msalClient - .AcquireTokenWithDeviceCode(scopes, deviceCodeResult => + // Fall back to interactive or device code based on profile's auth method + AuthenticationResult result; + + // Honor the profile's preferred auth method if it's interactive and available + if (_preferredAuthMethod == AuthMethod.InteractiveBrowser && + InteractiveBrowserCredentialProvider.IsAvailable()) + { + if (_deviceCodeCallback == null) { - if (_deviceCodeCallback != null) - { - _deviceCodeCallback(new DeviceCodeInfo( - deviceCodeResult.UserCode, - deviceCodeResult.VerificationUrl, - deviceCodeResult.Message)); - } - else + Console.WriteLine("Opening browser for authentication..."); + } + + result = await _msalClient! + .AcquireTokenInteractive(scopes) + .WithUseEmbeddedWebView(false) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } + else + { + // Fall back to device code flow + result = await _msalClient! + .AcquireTokenWithDeviceCode(scopes, deviceCodeResult => { - Console.WriteLine(); - Console.WriteLine("To sign in, use a web browser to open the page:"); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($" {deviceCodeResult.VerificationUrl}"); - Console.ResetColor(); - Console.WriteLine(); - Console.WriteLine("Enter the code:"); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($" {deviceCodeResult.UserCode}"); - Console.ResetColor(); - Console.WriteLine(); - Console.WriteLine("Waiting for authentication..."); - } - return Task.CompletedTask; - }) - .ExecuteAsync(cancellationToken) - .ConfigureAwait(false); + if (_deviceCodeCallback != null) + { + _deviceCodeCallback(new DeviceCodeInfo( + deviceCodeResult.UserCode, + deviceCodeResult.VerificationUrl, + deviceCodeResult.Message)); + } + else + { + Console.WriteLine(); + Console.WriteLine("To sign in, use a web browser to open the page:"); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($" {deviceCodeResult.VerificationUrl}"); + Console.ResetColor(); + Console.WriteLine(); + Console.WriteLine("Enter the code:"); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" {deviceCodeResult.UserCode}"); + Console.ResetColor(); + Console.WriteLine(); + Console.WriteLine("Waiting for authentication..."); + } + return Task.CompletedTask; + }) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } if (_deviceCodeCallback == null) { @@ -197,6 +227,12 @@ private Func> CreateTokenProviderFunction( }; } + /// + /// Finds the correct cached account for this profile. + /// + private Task FindAccountAsync() + => MsalAccountHelper.FindAccountAsync(_msalClient!, _homeAccountId, _tenantId); + /// /// Ensures the MSAL client is initialized with token cache. /// @@ -206,11 +242,13 @@ private async Task EnsureMsalClientInitializedAsync() return; var cloudInstance = CloudEndpoints.GetAzureCloudInstance(_cloud); - var tenant = string.IsNullOrWhiteSpace(_tenantId) ? "organizations" : _tenantId; + // Always use "organizations" (multi-tenant) authority for discovery. + // This ensures tokens cached during profile creation (also using "organizations") + // can be reused. Tenant-specific authority is only needed for environment connections. _msalClient = PublicClientApplicationBuilder .Create(MicrosoftPublicClientId) - .WithAuthority(cloudInstance, tenant) + .WithAuthority(cloudInstance, "organizations") .WithDefaultRedirectUri() .Build(); diff --git a/src/PPDS.Auth/Profiles/AuthProfile.cs b/src/PPDS.Auth/Profiles/AuthProfile.cs index 53dad2aaf..c8ce3511a 100644 --- a/src/PPDS.Auth/Profiles/AuthProfile.cs +++ b/src/PPDS.Auth/Profiles/AuthProfile.cs @@ -165,6 +165,13 @@ public sealed class AuthProfile [JsonPropertyName("puid")] public string? Puid { get; set; } + /// + /// Gets or sets the MSAL home account identifier. + /// Format: {objectId}.{tenantId} - uniquely identifies the account+tenant for token cache lookup. + /// + [JsonPropertyName("homeAccountId")] + public string? HomeAccountId { get; set; } + #endregion /// diff --git a/src/PPDS.Cli/CHANGELOG.md b/src/PPDS.Cli/CHANGELOG.md index c9fc755a4..f8e0758b2 100644 --- a/src/PPDS.Cli/CHANGELOG.md +++ b/src/PPDS.Cli/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-beta.3] - 2026-01-01 + +### Added + +- `entityAlias` field in plugin image configuration (defaults to `name` if not specified) +- `unsecureConfiguration` field for plugin step configuration (renamed from `configuration` for clarity) +- `MainOperation` stage (30) support for Custom API plugin steps +- JSON schema for `plugin-registration.json` at `schemas/plugin-registration.schema.json` +- Zulu time format (`Z` suffix) for `generatedAt` timestamps in extracted config + +### Fixed + +- Solution component addition now works on UPDATE path (was only adding on CREATE) ([#59](https://github.com/joshsmithxrm/ppds-sdk/issues/59)) +- Cross-platform path normalization uses forward slashes consistently +- Runtime ETC lookup for `pluginpackage` entity type code + ## [1.0.0-beta.2] - 2025-12-30 ### Added @@ -64,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Packaged as .NET global tool (`ppds`) - Targets: `net10.0` -[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.2...HEAD +[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.3...HEAD +[1.0.0-beta.3]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.2...Cli-v1.0.0-beta.3 [1.0.0-beta.2]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.1...Cli-v1.0.0-beta.2 [1.0.0-beta.1]: https://github.com/joshsmithxrm/ppds-sdk/releases/tag/Cli-v1.0.0-beta.1 diff --git a/src/PPDS.Cli/Commands/Auth/AuthCommandGroup.cs b/src/PPDS.Cli/Commands/Auth/AuthCommandGroup.cs index 8adda0126..1003d255b 100644 --- a/src/PPDS.Cli/Commands/Auth/AuthCommandGroup.cs +++ b/src/PPDS.Cli/Commands/Auth/AuthCommandGroup.cs @@ -286,6 +286,7 @@ private static async Task ExecuteCreateAsync(CreateOptions options, Cancell profile.Username = provider.Identity; profile.ObjectId = provider.ObjectId; profile.TokenExpiresOn = provider.TokenExpiresAt; + profile.HomeAccountId = provider.HomeAccountId; // Store tenant ID from auth result if not already set if (string.IsNullOrEmpty(profile.TenantId) && !string.IsNullOrEmpty(provider.TenantId)) diff --git a/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs b/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs index 13f5e3bb1..c2c4fd861 100644 --- a/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs +++ b/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs @@ -104,7 +104,8 @@ private static Task ExecuteAsync( var outputDir = Path.GetDirectoryName(outputPath) ?? "."; // Calculate relative path from output to input - var relativePath = Path.GetRelativePath(outputDir, input.FullName); + // Normalize to forward slashes for cross-platform compatibility + var relativePath = Path.GetRelativePath(outputDir, input.FullName).Replace('\\', '/'); if (assemblyConfig.Type == "Assembly") { assemblyConfig.Path = relativePath; diff --git a/src/PPDS.Cli/Commands/Plugins/ListCommand.cs b/src/PPDS.Cli/Commands/Plugins/ListCommand.cs index c5617ca9b..ebf5bb6f4 100644 --- a/src/PPDS.Cli/Commands/Plugins/ListCommand.cs +++ b/src/PPDS.Cli/Commands/Plugins/ListCommand.cs @@ -250,6 +250,7 @@ private static async Task PopulateTypesAsync( Deployment = step.Deployment, RunAsUser = step.ImpersonatingUserName, AsyncAutoDelete = step.AsyncAutoDelete, + UnsecureConfiguration = step.Configuration, Images = [] }; @@ -260,6 +261,7 @@ private static async Task PopulateTypesAsync( stepOutput.Images.Add(new ImageOutput { Name = image.Name, + EntityAlias = image.EntityAlias ?? image.Name, ImageType = image.ImageType, Attributes = image.Attributes }); @@ -422,6 +424,9 @@ private sealed class StepOutput [JsonPropertyName("asyncAutoDelete")] public bool AsyncAutoDelete { get; set; } + [JsonPropertyName("unsecureConfiguration")] + public string? UnsecureConfiguration { get; set; } + [JsonPropertyName("images")] public List Images { get; set; } = []; } @@ -431,6 +436,9 @@ private sealed class ImageOutput [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("entityAlias")] + public string EntityAlias { get; set; } = string.Empty; + [JsonPropertyName("imageType")] public string ImageType { get; set; } = string.Empty; diff --git a/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs b/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs index d6828943c..35f0efee7 100644 --- a/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs +++ b/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs @@ -179,7 +179,7 @@ private static PluginStepConfig MapStepAttribute(CustomAttributeData attr, Type step.Name = value?.ToString(); break; case "UnsecureConfiguration": - step.Configuration = value?.ToString(); + step.UnsecureConfiguration = value?.ToString(); break; case "Description": step.Description = value?.ToString(); @@ -249,6 +249,9 @@ private static PluginImageConfig MapImageAttribute(CustomAttributeData attr) } } + // Default entityAlias to name if not explicitly specified + image.EntityAlias ??= image.Name; + return image; } @@ -280,6 +283,7 @@ private static string MapStageValue(object? value) { 10 => "PreValidation", 20 => "PreOperation", + 30 => "MainOperation", 40 => "PostOperation", _ => intValue.ToString() }; diff --git a/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs b/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs index 9ac542ee0..64a3f0c3f 100644 --- a/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs +++ b/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs @@ -1,8 +1,33 @@ +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; namespace PPDS.Cli.Plugins.Models; +/// +/// Converts DateTimeOffset to/from Zulu time format (yyyy-MM-ddTHH:mm:ssZ). +/// +public sealed class ZuluTimeConverter : JsonConverter +{ + public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return string.IsNullOrEmpty(value) ? null : DateTimeOffset.Parse(value, CultureInfo.InvariantCulture); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options) + { + if (value.HasValue) + { + writer.WriteStringValue(value.Value.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ")); + } + else + { + writer.WriteNullValue(); + } + } +} + /// /// Root configuration for plugin registrations. /// Serialized to/from registrations.json. @@ -25,6 +50,7 @@ public sealed class PluginRegistrationConfig /// Timestamp when the configuration was generated. /// [JsonPropertyName("generatedAt")] + [JsonConverter(typeof(ZuluTimeConverter))] public DateTimeOffset? GeneratedAt { get; set; } /// @@ -175,9 +201,10 @@ public sealed class PluginStepConfig /// /// Unsecure configuration string passed to plugin constructor. + /// Safe for source control (never contains secrets). /// - [JsonPropertyName("configuration")] - public string? Configuration { get; set; } + [JsonPropertyName("unsecureConfiguration")] + public string? UnsecureConfiguration { get; set; } /// /// Deployment target: ServerOnly (default), Offline, or Both. diff --git a/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs b/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs index 3d7b709c7..05703ab49 100644 --- a/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs +++ b/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs @@ -1,6 +1,8 @@ using Microsoft.Crm.Sdk.Messages; using Microsoft.PowerPlatform.Dataverse.Client; using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Messages; +using Microsoft.Xrm.Sdk.Metadata; using Microsoft.Xrm.Sdk.Query; using PPDS.Cli.Plugins.Models; @@ -14,6 +16,16 @@ public sealed class PluginRegistrationService private readonly IOrganizationService _service; private readonly IOrganizationServiceAsync2? _asyncService; + // Cache for entity type codes (ETCs) - some like pluginpackage vary by environment + private readonly Dictionary _entityTypeCodeCache = new(); + + // Well-known component type codes that are consistent across all environments + private static readonly Dictionary WellKnownComponentTypes = new() + { + ["pluginassembly"] = 91, + ["sdkmessageprocessingstep"] = 92 + }; + public PluginRegistrationService(IOrganizationService service) { _service = service; @@ -357,6 +369,13 @@ public async Task UpsertAssemblyAsync(string name, byte[] content, string? { entity.Id = existing.Id; await UpdateAsync(entity); + + // Add to solution even on update (handles case where component exists but isn't in solution) + if (!string.IsNullOrEmpty(solutionName)) + { + await AddToSolutionAsync(existing.Id, 91, solutionName); // 91 = Plugin Assembly + } + return existing.Id; } else @@ -452,9 +471,9 @@ public async Task UpsertStepAsync( entity["filteringattributes"] = stepConfig.FilteringAttributes; } - if (!string.IsNullOrEmpty(stepConfig.Configuration)) + if (!string.IsNullOrEmpty(stepConfig.UnsecureConfiguration)) { - entity["configuration"] = stepConfig.Configuration; + entity["configuration"] = stepConfig.UnsecureConfiguration; } if (!string.IsNullOrEmpty(stepConfig.Description)) @@ -482,6 +501,13 @@ public async Task UpsertStepAsync( { entity.Id = existing.Id; await UpdateAsync(entity); + + // Add to solution even on update (handles case where component exists but isn't in solution) + if (!string.IsNullOrEmpty(solutionName)) + { + await AddToSolutionAsync(existing.Id, 92, solutionName); // 92 = SDK Message Processing Step + } + return existing.Id; } else @@ -604,18 +630,52 @@ public async Task AddToSolutionAsync(Guid componentId, int componentType, string #region Private Helpers + /// + /// Gets the solution component type code for an entity. + /// Uses well-known values for system entities, queries metadata for custom entities like pluginpackage. + /// + private async Task GetComponentTypeAsync(string entityLogicalName) + { + // Check well-known types first (no API call needed) + if (WellKnownComponentTypes.TryGetValue(entityLogicalName, out var wellKnownType)) + { + return wellKnownType; + } + + // Check cache + if (_entityTypeCodeCache.TryGetValue(entityLogicalName, out var cachedType)) + { + return cachedType; + } + + // Query entity metadata to get ObjectTypeCode + var request = new RetrieveEntityRequest + { + LogicalName = entityLogicalName, + EntityFilters = EntityFilters.Entity + }; + + try + { + var response = (RetrieveEntityResponse)await ExecuteAsync(request); + var objectTypeCode = response.EntityMetadata.ObjectTypeCode ?? 0; + _entityTypeCodeCache[entityLogicalName] = objectTypeCode; + return objectTypeCode; + } + catch + { + // Entity doesn't exist or can't be queried - return 0 to skip solution addition + return 0; + } + } + private async Task CreateWithSolutionAsync(Entity entity, string? solutionName) { var id = await CreateAsync(entity); if (!string.IsNullOrEmpty(solutionName)) { - var componentType = entity.LogicalName switch - { - "pluginassembly" => 91, - "sdkmessageprocessingstep" => 92, - _ => 0 - }; + var componentType = await GetComponentTypeAsync(entity.LogicalName); if (componentType > 0) { @@ -630,6 +690,7 @@ private async Task CreateWithSolutionAsync(Entity entity, string? solution { 10 => "PreValidation", 20 => "PreOperation", + 30 => "MainOperation", 40 => "PostOperation", _ => value.ToString() }; @@ -653,6 +714,7 @@ private async Task CreateWithSolutionAsync(Entity entity, string? solution { "PreValidation" => 10, "PreOperation" => 20, + "MainOperation" => 30, "PostOperation" => 40, _ => int.TryParse(stage, out var v) ? v : 40 }; diff --git a/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs b/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs index 7fce6f1ce..3ac01633d 100644 --- a/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs +++ b/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs @@ -262,7 +262,7 @@ public void RoundTrip_PreservesAllProperties() Mode = "Synchronous", ExecutionOrder = 10, FilteringAttributes = null, - Configuration = "some config", + UnsecureConfiguration = "some config", StepId = "step1", Images = [] } @@ -293,7 +293,7 @@ public void RoundTrip_PreservesAllProperties() Assert.Equal(origStep.Name, deserStep.Name); Assert.Equal(origStep.Message, deserStep.Message); Assert.Equal(origStep.ExecutionOrder, deserStep.ExecutionOrder); - Assert.Equal(origStep.Configuration, deserStep.Configuration); + Assert.Equal(origStep.UnsecureConfiguration, deserStep.UnsecureConfiguration); Assert.Equal(origStep.StepId, deserStep.StepId); }