diff --git a/src/Nox.Cli.Abstractions/Secrets/IPersistedSecretStoreEx.cs b/src/Nox.Cli.Abstractions/Secrets/IPersistedSecretStoreEx.cs new file mode 100644 index 0000000..2c18f47 --- /dev/null +++ b/src/Nox.Cli.Abstractions/Secrets/IPersistedSecretStoreEx.cs @@ -0,0 +1,9 @@ +using Nox.Secrets.Abstractions; +namespace Nox.Cli.Abstractions.Secrets; +public interface IPersistedSecretStoreEx: IPersistedSecretStore +{ +#if NET8_0 + Task SaveAsync(string key, string secret); + Task LoadAsync(string key, TimeSpan? validFor = null); +#endif +} \ No newline at end of file diff --git a/src/Nox.Cli.Plugins/Nox.Cli.Plugin.AzDevOps/AzDevOpsVerifyAadGroups_v1.cs b/src/Nox.Cli.Plugins/Nox.Cli.Plugin.AzDevOps/AzDevOpsVerifyAadGroups_v1.cs new file mode 100644 index 0000000..63bc1f4 --- /dev/null +++ b/src/Nox.Cli.Plugins/Nox.Cli.Plugin.AzDevOps/AzDevOpsVerifyAadGroups_v1.cs @@ -0,0 +1,113 @@ +using Nox.Cli.Abstractions; +using Nox.Cli.Abstractions.Extensions; +using Nox.Cli.Plugin.AzDevOps.Clients; +using Nox.Cli.Plugin.AzDevOps.Enums; +namespace Nox.Cli.Plugin.AzDevOps; + +public class AzDevOpsVerifyAadGroup_v1 : INoxCliAddin +{ + public NoxActionMetaData Discover() + { + return new NoxActionMetaData + { + Name = "azdevops/verify-aad-group@v1", + Author = "Jan Schutte", + Description = "Verify that an AAD group ia available to a DevOps project group", + Inputs = + { + ["server"] = new NoxActionInput { + Id = "server", + Description = "The DevOps server hostname or IP", + Default = "localhost", + IsRequired = true + }, + + ["personal-access-token"] = new NoxActionInput { + Id = "personal-access-token", + Description = "The personal access token to connect to DevOps with", + Default = string.Empty, + IsRequired = true + }, + ["project-id"] = new NoxActionInput + { + Id = "project-id", + Description = "The DevOps project Id", + Default = Guid.Empty, + IsRequired = true + }, + ["aad-group-name"] = new NoxActionInput + { + Id = "aad-group-name", + Description = "The AAD group to verify", + Default = Guid.Empty, + IsRequired = true + } + }, + Outputs = + { + ["is-found"] = new NoxActionOutput { + Id = "is-found", + Description = "A boolean indicating if the AAD group was found.", + } + } + }; + } + private string? _server; + private string? _pat; + private Guid? _projectId; + private string? _aadGroupName; + private bool _isServerContext = false; + + public Task BeginAsync(IDictionary inputs) + { + _server = inputs.Value("server"); + _pat = inputs.Value("personal-access-token"); + _projectId = inputs.Value("project-id"); + _aadGroupName = inputs.Value("aad-group-name"); + return Task.CompletedTask; + } + public async Task> ProcessAsync(INoxWorkflowContext ctx) + { + _isServerContext = ctx.IsServer; + var outputs = new Dictionary(); + ctx.SetState(ActionState.Error); + + if (string.IsNullOrWhiteSpace(_server) || + string.IsNullOrWhiteSpace(_pat) || + _projectId == null || + _projectId == Guid.Empty || + string.IsNullOrEmpty(_aadGroupName)) + { + ctx.SetErrorMessage("The devops verify-aad-group action was not initialized"); + } + else + { + try + { + var result = await FindGroup(ctx); + outputs["is-found"] = result; + ctx.SetState(ActionState.Success); + } + catch (Exception ex) + { + ctx.SetErrorMessage(ex.Message); + } + } + return outputs; + } + public Task EndAsync() + { + return Task.CompletedTask; + } + private async Task FindGroup(INoxWorkflowContext ctx) + { + var identityPickerClient = new IdentityPickerClient(_server!, _pat!); + var aadGroups = await identityPickerClient.FindIdentity(_aadGroupName!, IdentityType.Group); + if (aadGroups == null || aadGroups.Count == 0) + { + return false; + } + return true; + } + +} \ No newline at end of file diff --git a/src/Nox.Cli.Plugins/Nox.Cli.Plugin.AzDevOps/AzDevopsAddProjectAadGroup_v1.cs b/src/Nox.Cli.Plugins/Nox.Cli.Plugin.AzDevOps/AzDevopsAddProjectAadGroup_v1.cs index 32c2ee6..4fba147 100644 --- a/src/Nox.Cli.Plugins/Nox.Cli.Plugin.AzDevOps/AzDevopsAddProjectAadGroup_v1.cs +++ b/src/Nox.Cli.Plugins/Nox.Cli.Plugin.AzDevOps/AzDevopsAddProjectAadGroup_v1.cs @@ -118,7 +118,7 @@ private async Task AddAdmins(INoxWorkflowContext ctx) var graphClient = new GraphClient(_server!, _pat!); var aadGroups = await identityPickerClient.FindIdentity(_aadGroupName!, IdentityType.Group); - if (aadGroups == null) + if (aadGroups == null || aadGroups.Count == 0) { ctx.SetErrorMessage($"Unable to locate the AAD group: {_aadGroupName}"); return false; diff --git a/src/Nox.Cli.Variables/Secrets/IPersistedSecretStore.cs b/src/Nox.Cli.Variables/Secrets/IPersistedSecretStore.cs deleted file mode 100644 index 1d8936c..0000000 --- a/src/Nox.Cli.Variables/Secrets/IPersistedSecretStore.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Nox.Cli.Variables.Secrets; - -public interface IPersistedSecretStore -{ - void Save(string key, string secret); - - string? Load(string key, TimeSpan? validFor = null); - -#if NET8_0 - Task SaveAsync(string key, string secret); - Task LoadAsync(string key, TimeSpan? validFor = null); -#endif -} \ No newline at end of file diff --git a/src/Nox.Cli.Variables/Secrets/OrgSecretResolver.cs b/src/Nox.Cli.Variables/Secrets/OrgSecretResolver.cs index 18bbb61..53d36bd 100755 --- a/src/Nox.Cli.Variables/Secrets/OrgSecretResolver.cs +++ b/src/Nox.Cli.Variables/Secrets/OrgSecretResolver.cs @@ -1,12 +1,14 @@ using Nox.Cli.Abstractions.Configuration; +using Nox.Cli.Abstractions.Secrets; +using Nox.Secrets.Abstractions; namespace Nox.Cli.Variables.Secrets; public class OrgSecretResolver: IOrgSecretResolver { - private readonly IPersistedSecretStore _store; + private readonly IPersistedSecretStoreEx _store; - public OrgSecretResolver(IPersistedSecretStore store) + public OrgSecretResolver(IPersistedSecretStoreEx store) { _store = store; } diff --git a/src/Nox.Cli.Variables/Secrets/PersistedSecretStore.cs b/src/Nox.Cli.Variables/Secrets/PersistedSecretStore.cs index 14fe3e6..9e90883 100644 --- a/src/Nox.Cli.Variables/Secrets/PersistedSecretStore.cs +++ b/src/Nox.Cli.Variables/Secrets/PersistedSecretStore.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.DataProtection; +using Nox.Cli.Abstractions.Secrets; using Nox.Solution.Constants; using Nox.Types; using DateTime = System.DateTime; @@ -6,7 +7,7 @@ namespace Nox.Cli.Variables.Secrets; -public class PersistedSecretStore: IPersistedSecretStore +public class PersistedSecretStore: IPersistedSecretStoreEx { private readonly IDataProtector _protector; private const string ProtectorPurpose = "nox-secrets"; diff --git a/src/Nox.Cli.Variables/Secrets/ServerSecretResolver.cs b/src/Nox.Cli.Variables/Secrets/ServerSecretResolver.cs index 58e18f0..eca6e88 100755 --- a/src/Nox.Cli.Variables/Secrets/ServerSecretResolver.cs +++ b/src/Nox.Cli.Variables/Secrets/ServerSecretResolver.cs @@ -2,6 +2,7 @@ using Azure.Identity; using Azure.Security.KeyVault.Secrets; using Nox.Cli.Abstractions.Configuration; +using Nox.Cli.Abstractions.Secrets; using Nox.Cli.Server.Abstractions; namespace Nox.Cli.Variables.Secrets; @@ -10,12 +11,12 @@ public class ServerSecretResolver: IServerSecretResolver { private static readonly Regex SecretsVariableRegex = new(@"\$\{\{\s*server\.secrets\.(?[\w\.\-_:]+)\s*\}\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private readonly IPersistedSecretStore _store; + private readonly IPersistedSecretStoreEx _store; private readonly string _tenantId; private readonly string _clientId; private readonly string _clientSecret; - public ServerSecretResolver(IPersistedSecretStore store, string tenantId, string clientId, string clientSecret) + public ServerSecretResolver(IPersistedSecretStoreEx store, string tenantId, string clientId, string clientSecret) { _store = store; _tenantId = tenantId; diff --git a/src/Nox.Cli.Variables/Secrets/ServiceExtensions.cs b/src/Nox.Cli.Variables/Secrets/ServiceExtensions.cs index 7912f50..6b1399e 100755 --- a/src/Nox.Cli.Variables/Secrets/ServiceExtensions.cs +++ b/src/Nox.Cli.Variables/Secrets/ServiceExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using Nox.Cli.Abstractions.Secrets; +using Nox.Secrets.Abstractions; namespace Nox.Cli.Variables.Secrets; @@ -12,14 +14,15 @@ public static IServiceCollection AddOrgSecretResolver(this IServiceCollection se public static IServiceCollection AddServerSecretResolver(this IServiceCollection services, string tenantId, string clientId, string clientSecret) { - services.AddSingleton(sp => new ServerSecretResolver(sp.GetRequiredService(), tenantId, clientId, clientSecret)); + services.AddSingleton(sp => new ServerSecretResolver(sp.GetRequiredService(), tenantId, clientId, clientSecret)); return services; } public static IServiceCollection AddPersistedSecretStore(this IServiceCollection services) { services.AddDataProtection(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } } \ No newline at end of file diff --git a/src/Nox.Cli.sln b/src/Nox.Cli.sln index d5c0b82..a8c88b8 100755 --- a/src/Nox.Cli.sln +++ b/src/Nox.Cli.sln @@ -83,6 +83,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plugin.Git.Tests", "..\test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestHelpers", "TestHelpers\TestHelpers.csproj", "{413D3BDF-2572-4DD0-A2F7-5FBEFD9DAC19}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plugin.AzDevOps.Tests", "..\tests\Plugin.AzDevOps.Tests\Plugin.AzDevOps.Tests.csproj", "{8617ACC1-A6C8-4E26-BD79-E9A9D854D0BE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -221,6 +223,10 @@ Global {413D3BDF-2572-4DD0-A2F7-5FBEFD9DAC19}.Debug|Any CPU.Build.0 = Debug|Any CPU {413D3BDF-2572-4DD0-A2F7-5FBEFD9DAC19}.Release|Any CPU.ActiveCfg = Release|Any CPU {413D3BDF-2572-4DD0-A2F7-5FBEFD9DAC19}.Release|Any CPU.Build.0 = Release|Any CPU + {8617ACC1-A6C8-4E26-BD79-E9A9D854D0BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8617ACC1-A6C8-4E26-BD79-E9A9D854D0BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8617ACC1-A6C8-4E26-BD79-E9A9D854D0BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8617ACC1-A6C8-4E26-BD79-E9A9D854D0BE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -260,6 +266,7 @@ Global {45F0637F-945A-42BD-8A2D-5FCFFDA741C8} = {E3FA4748-9B96-42E7-BDE8-1F08AA8AD4D9} {8309A464-7247-4939-AE15-C59B5EC3D516} = {BDFC35C7-EFDC-4502-B69B-45ECB3C805CE} {413D3BDF-2572-4DD0-A2F7-5FBEFD9DAC19} = {70B359E7-710C-4583-99A0-1F59333C3AD1} + {8617ACC1-A6C8-4E26-BD79-E9A9D854D0BE} = {BDFC35C7-EFDC-4502-B69B-45ECB3C805CE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B440C24E-F11D-4F28-9EE5-F7AB214679E6} diff --git a/tests/Plugin.AzDevOps.Tests/DevOpsIntegrationFixture.cs b/tests/Plugin.AzDevOps.Tests/DevOpsIntegrationFixture.cs new file mode 100644 index 0000000..56a790f --- /dev/null +++ b/tests/Plugin.AzDevOps.Tests/DevOpsIntegrationFixture.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Nox; +using Nox.Cli.Abstractions.Caching; +using Nox.Cli.Caching; +using Nox.Cli.Configuration; +using Nox.Cli.PersonalAccessToken; +using Nox.Cli.Variables.Secrets; +using Nox.Secrets.Abstractions; +using Nox.Solution; +namespace Plugin.AzDevOps.Tests; + +public class DevOpsIntegrationFixture +{ + public IServiceProvider ServiceProvider { get; private set; } + public IServiceCollection? Services { get; private set; } + public DevOpsIntegrationFixture() + { + Services = new ServiceCollection(); + Services.AddSingleton(Mock.Of()); + Services.AddOrgSecretResolver(); + Services.AddSingleton(Mock.Of()); + Services.AddPersistedSecretStore(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddNoxTokenCache(); + var onlineCacheUrl = "https://noxorg.dev"; + var persistedTokenCache = Services.BuildServiceProvider().GetRequiredService(); + var cacheBuilder = new NoxCliCacheBuilder(onlineCacheUrl, false, persistedTokenCache); + var cacheManager = cacheBuilder.Build(); + Services.AddNoxCliCacheManager(cacheManager); + ServiceProvider = Services.BuildServiceProvider(); + } +} \ No newline at end of file diff --git a/tests/Plugin.AzDevOps.Tests/GroupTests.cs b/tests/Plugin.AzDevOps.Tests/GroupTests.cs new file mode 100644 index 0000000..e16d7c1 --- /dev/null +++ b/tests/Plugin.AzDevOps.Tests/GroupTests.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Nox.Cli.Abstractions.Caching; +using Nox.Cli.Actions; +using Nox.Cli.Caching; +using Nox.Cli.Configuration; +using Nox.Cli.PersonalAccessToken; +using Nox.Cli.Plugin.AzDevOps; +using Nox.Cli.Variables.Secrets; +using Nox.Secrets.Abstractions; +using Nox.Solution; +namespace Plugin.AzDevOps.Tests; +public class GroupTests: IClassFixture +{ + private readonly DevOpsIntegrationFixture _fixture; + public GroupTests(DevOpsIntegrationFixture fixture) + { + _fixture = fixture; + } + + [Theory] + [InlineData("NOX_PROJECTS_ALL", true)] + [InlineData("NOX_PROJECT_DOESNOTEXIST", false)] + public async Task Can_verify_whether_an_aad_group_exists_or_not(string groupName, bool result) + { + var wfConfig = new WorkflowConfiguration(); + var sln = _fixture.ServiceProvider.GetRequiredService(); + var orgResolver = _fixture.ServiceProvider.GetRequiredService(); + var cacheManager = _fixture.ServiceProvider.GetRequiredService(); + var lteConfig = _fixture.ServiceProvider.GetRequiredService(); + var secretsResolver = _fixture.ServiceProvider.GetRequiredService(); + var tokenCache = _fixture.ServiceProvider.GetRequiredService(); + var accessToken = await CredentialHelper.GetAzureDevOpsAccessToken(); + var patProvider = new AzDevOpsPatProvider(tokenCache, "iwgplc"); + var pat = await patProvider.GetPat(accessToken!); + + var plugin = new AzDevOpsVerifyAadGroup_v1(); + var inputs = new Dictionary + { + {"server", "https://dev.azure.com/iwgplc"}, + {"personal-access-token", pat}, + {"project-id", "d6aee400-9659-4dec-a309-673518d4cc30"}, + {"aad-group-name", groupName} + }; + await plugin.BeginAsync(inputs); + var ctx = new NoxWorkflowContext(wfConfig, sln, orgResolver, cacheManager, lteConfig, secretsResolver, null!); + var pluginOutput = await plugin.ProcessAsync(ctx); + Assert.Equal(result, pluginOutput["is-found"]); + } +} \ No newline at end of file diff --git a/tests/Plugin.AzDevOps.Tests/Plugin.AzDevOps.Tests.csproj b/tests/Plugin.AzDevOps.Tests/Plugin.AzDevOps.Tests.csproj new file mode 100644 index 0000000..b2aa1c4 --- /dev/null +++ b/tests/Plugin.AzDevOps.Tests/Plugin.AzDevOps.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Plugin.Core.Tests/SnakeNameTests.cs b/tests/Plugin.Core.Tests/SnakeNameTests.cs index 683d90e..0756b65 100644 --- a/tests/Plugin.Core.Tests/SnakeNameTests.cs +++ b/tests/Plugin.Core.Tests/SnakeNameTests.cs @@ -11,7 +11,7 @@ namespace Plugin.Core.Tests; public class SnakeNameTests { - [Theory] + [Theory (Skip = "This test can only be run locally as it requires a DevOps PAT")] [InlineData("Hello.World", "hello_world")] [InlineData("HelloWorld", "helloworld")] [InlineData("HELLO.World", "hello_world")]