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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions schemas/plugin-registration.schema.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
13 changes: 12 additions & 1 deletion src/PPDS.Auth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public sealed class AzureDevOpsFederatedCredentialProvider : ICredentialProvider
/// <inheritdoc />
public string? ObjectId => null; // Not available for federated auth without additional calls

/// <inheritdoc />
public string? HomeAccountId => null; // Federated auth doesn't use MSAL user cache

/// <inheritdoc />
public string? AccessToken => _cachedToken?.Token;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
/// <inheritdoc />
public string? ObjectId => null; // Service principals don't have a user OID

/// <inheritdoc />
public string? HomeAccountId => null; // Service principals don't use MSAL user cache

/// <inheritdoc />
public string? AccessToken => null; // Connection string auth doesn't expose the token

Expand Down Expand Up @@ -154,8 +157,8 @@
var flags = X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet;

_certificate = string.IsNullOrEmpty(_certificatePassword)
? new X509Certificate2(_certificatePath, (string?)null, flags)

Check warning on line 160 in src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs

View workflow job for this annotation

GitHub Actions / test

'X509Certificate2.X509Certificate2(string, string?, X509KeyStorageFlags)' is obsolete: 'Loading certificate data through the constructor or Import is obsolete. Use X509CertificateLoader instead to load certificates.' (https://aka.ms/dotnet-warnings/SYSLIB0057)
: new X509Certificate2(_certificatePath, _certificatePassword, flags);

Check warning on line 161 in src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs

View workflow job for this annotation

GitHub Actions / test

'X509Certificate2.X509Certificate2(string, string?, X509KeyStorageFlags)' is obsolete: 'Loading certificate data through the constructor or Import is obsolete. Use X509CertificateLoader instead to load certificates.' (https://aka.ms/dotnet-warnings/SYSLIB0057)

if (!_certificate.HasPrivateKey)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public sealed class CertificateStoreCredentialProvider : ICredentialProvider
/// <inheritdoc />
public string? ObjectId => null; // Service principals don't have a user OID

/// <inheritdoc />
public string? HomeAccountId => null; // Service principals don't use MSAL user cache

/// <inheritdoc />
public string? AccessToken => null; // Connection string auth doesn't expose the token

Expand Down
3 changes: 3 additions & 0 deletions src/PPDS.Auth/Credentials/ClientSecretCredentialProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public sealed class ClientSecretCredentialProvider : ICredentialProvider
/// <inheritdoc />
public string? ObjectId => null; // Service principals don't have a user OID

/// <inheritdoc />
public string? HomeAccountId => null; // Service principals don't use MSAL user cache

/// <inheritdoc />
public string? AccessToken => null; // Connection string auth doesn't expose the token

Expand Down
25 changes: 17 additions & 8 deletions src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeviceCodeInfo>? _deviceCodeCallback;

private IPublicClientApplication? _msalClient;
Expand All @@ -48,6 +49,9 @@ public sealed class DeviceCodeCredentialProvider : ICredentialProvider
/// <inheritdoc />
public string? ObjectId => _cachedResult?.UniqueId;

/// <inheritdoc />
public string? HomeAccountId => _cachedResult?.Account?.HomeAccountId?.Identifier;

/// <inheritdoc />
public string? AccessToken => _cachedResult?.AccessToken;

Expand All @@ -60,16 +64,19 @@ public sealed class DeviceCodeCredentialProvider : ICredentialProvider
/// <param name="cloud">The cloud environment.</param>
/// <param name="tenantId">Optional tenant ID (defaults to "organizations" for multi-tenant).</param>
/// <param name="username">Optional username for silent auth lookup.</param>
/// <param name="homeAccountId">Optional MSAL home account identifier for precise account lookup.</param>
/// <param name="deviceCodeCallback">Optional callback for displaying device code (defaults to console output).</param>
public DeviceCodeCredentialProvider(
CloudEnvironment cloud = CloudEnvironment.Public,
string? tenantId = null,
string? username = null,
string? homeAccountId = null,
Action<DeviceCodeInfo>? deviceCodeCallback = null)
{
_cloud = cloud;
_tenantId = tenantId;
_username = username;
_homeAccountId = homeAccountId;
_deviceCodeCallback = deviceCodeCallback;
}

Expand All @@ -87,6 +94,7 @@ public static DeviceCodeCredentialProvider FromProfile(
profile.Cloud,
profile.TenantId,
profile.Username,
profile.HomeAccountId,
deviceCodeCallback);
}

Expand Down Expand Up @@ -143,19 +151,14 @@ private async Task<string> 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);
Expand Down Expand Up @@ -209,6 +212,12 @@ private async Task<string> GetTokenAsync(string environmentUrl, bool forceIntera
return _cachedResult.AccessToken;
}

/// <summary>
/// Finds the correct cached account for this profile.
/// </summary>
private Task<IAccount?> FindAccountAsync()
=> MsalAccountHelper.FindAccountAsync(_msalClient!, _homeAccountId, _tenantId, _username);

/// <summary>
/// Ensures the MSAL client is initialized with token cache.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public sealed class GitHubFederatedCredentialProvider : ICredentialProvider
/// <inheritdoc />
public string? ObjectId => null; // Not available for federated auth without additional calls

/// <inheritdoc />
public string? HomeAccountId => null; // Federated auth doesn't use MSAL user cache

/// <inheritdoc />
public string? AccessToken => _cachedToken?.Token;

Expand Down
7 changes: 7 additions & 0 deletions src/PPDS.Auth/Credentials/ICredentialProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ Task<ServiceClient> CreateServiceClientAsync(
/// </summary>
string? ObjectId { get; }

/// <summary>
/// Gets the MSAL home account identifier.
/// Format: {objectId}.{tenantId} - uniquely identifies the account+tenant for token cache lookup.
/// Available after successful authentication.
/// </summary>
string? HomeAccountId { get; }

/// <summary>
/// Gets the access token from the last authentication.
/// Available after successful authentication. Used for extracting JWT claims.
Expand Down
Loading
Loading