diff --git a/Commander/PAM/PamCommandBase.cs b/Commander/PAM/PamCommandBase.cs index 3a0cfa3f..491db55d 100644 --- a/Commander/PAM/PamCommandBase.cs +++ b/Commander/PAM/PamCommandBase.cs @@ -8,86 +8,96 @@ namespace Commander.PAM { - internal abstract class PamCommandBase + internal abstract class PamCommandBase + { + protected IEnterpriseContext Context { get; } + protected IPamPlugin Plugin { get; private set; } + + protected PamCommandBase(IEnterpriseContext context) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + } + + protected async Task EnsurePluginAsync(bool syncIfNeeded = true) { - protected IEnterpriseContext Context { get; } - protected IPamPlugin Plugin { get; private set; } + Plugin = Context.GetPamPlugin(); + if (Plugin == null) + { + Console.WriteLine("PAM plugin is not available. Enterprise admin access is required."); + return false; + } - protected PamCommandBase(IEnterpriseContext context) - { - Context = context ?? throw new ArgumentNullException(nameof(context)); - } + if (syncIfNeeded && !Plugin.Controllers.GetAll().Any()) + { + Console.WriteLine("Syncing PAM data..."); + await Plugin.SyncDownAsync(); + } - protected async Task EnsurePluginAsync(bool syncIfNeeded = true) - { - Plugin = Context.GetPamPlugin(); - if (Plugin == null) - { - Console.WriteLine("PAM plugin is not available. Enterprise admin access is required."); - return false; - } + return true; + } - if (syncIfNeeded && !Plugin.Controllers.GetAll().Any()) - { - Console.WriteLine("Syncing PAM data..."); - await Plugin.SyncDownAsync(); - } + protected PamController ResolveGateway(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + { + return null; + } - return true; - } + var controllers = Plugin.Controllers.GetAll(); + var controller = GatewayUtils.FindGateway(controllers, identifier); + if (controller != null) + { + return controller; + } - protected PamController ResolveGateway(string identifier) - { - if (string.IsNullOrWhiteSpace(identifier)) - { - return null; - } + var trimmed = identifier.Trim(); + var nameMatches = controllers + .Count(c => string.Equals(c.ControllerName, trimmed, StringComparison.OrdinalIgnoreCase)); + if (nameMatches > 1) + { + throw new PamGatewayAmbiguousException(trimmed); + } - var controllers = Plugin.Controllers.GetAll(); - var controller = GatewayUtils.FindGateway(controllers, identifier); - if (controller != null) - { - return controller; - } + return null; + } - var trimmed = identifier.Trim(); - var nameMatches = controllers - .Count(c => string.Equals(c.ControllerName, trimmed, StringComparison.OrdinalIgnoreCase)); - if (nameMatches > 1) - { - throw new PamGatewayAmbiguousException(trimmed); - } + protected static TypedRecord TryResolvePamRecord( + VaultOnline vault, + string identifier, + IEnumerable allowedTypes) + { + try + { + return PamVaultHelpers.ResolveRecord(vault, identifier, allowedTypes); + } + catch (InvalidOperationException ex) + { + Console.WriteLine(ex.Message); + return null; + } + } - return null; - } + protected VaultContext TryGetVaultContext() + { + if (Context is ConnectedContext connected) + { + return connected._vaultContext; + } - protected static TypedRecord TryResolvePamRecord( - VaultOnline vault, - string identifier, - IEnumerable allowedTypes) - { - try - { - return PamVaultHelpers.ResolveRecord(vault, identifier, allowedTypes); - } - catch (InvalidOperationException ex) - { - Console.WriteLine(ex.Message); - return null; - } - } + return null; + } - protected async Task SyncPamAsync(bool reload = false) - { - if (Plugin == null) - { - Plugin = Context.GetPamPlugin(); - } + protected async Task SyncPamAsync(bool reload = false) + { + if (Plugin == null) + { + Plugin = Context.GetPamPlugin(); + } - if (Plugin != null) - { - await Plugin.SyncDownAsync(reload); - } - } + if (Plugin != null) + { + await Plugin.SyncDownAsync(reload); + } } + } } diff --git a/Commander/PAM/PamConfigCommand.cs b/Commander/PAM/PamConfigCommand.cs new file mode 100644 index 00000000..b94b5c60 --- /dev/null +++ b/Commander/PAM/PamConfigCommand.cs @@ -0,0 +1,734 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Cli; +using Commander; +using CommandLine; +using KeeperSecurity.Plugins.PAM; +using KeeperSecurity.Utils; +using KeeperSecurity.Vault; +using ZeroDep; + +namespace Commander.PAM +{ + internal class PamConfigCommand : PamCommandBase + { + public PamConfigCommand(IEnterpriseContext context) : base(context) + { + } + + public async Task ExecuteAsync(PamConfigOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options), "Invalid pam config command arguments. Available commands: list, new, edit, remove"); + } + + var command = string.IsNullOrEmpty(options.Command) ? "list" : options.Command.Trim().ToLowerInvariant(); + switch (command) + { + case "list": + case "l": + await ListConfigurationsAsync(options); + break; + case "new": + case "n": + await NewConfigurationAsync(options); + break; + case "edit": + case "e": + if (string.IsNullOrWhiteSpace(options.Uid)) + { + throw new InvalidOperationException("Configuration UID or name is required for edit"); + } + + await EditConfigurationAsync(options); + break; + case "remove": + case "rm": + case "delete": + if (string.IsNullOrWhiteSpace(options.Uid)) + { + throw new InvalidOperationException("Configuration UID is required for remove"); + } + + await RemoveConfigurationAsync(options); + break; + default: + throw new InvalidOperationException("Unsupported command. Available: list, new, edit, remove"); + } + } + + private async Task ListConfigurationsAsync(PamConfigOptions options) + { + var vault = RequireVault(); + if (!await EnsurePluginAsync(syncIfNeeded: false)) + { + return; + } + + if (!string.IsNullOrWhiteSpace(options.Uid)) + { + await ListSingleConfigurationAsync(vault, options, options.Uid); + return; + } + + var configs = PamVaultHelpers.GetConfigurationRecords(vault).Values + .OrderBy(x => x.Title ?? string.Empty, StringComparer.OrdinalIgnoreCase); + + if (options.isFormatOutputJSON) + { + ListConfigurationsAsJson(vault, configs, options.Verbose); + } + else + { + ListConfigurationsAsTable(vault, configs, options.Verbose); + } + } + + private static void ListConfigurationsAsJson( + VaultOnline vault, + IEnumerable configs, + bool verbose) + { + var rows = new List>(); + foreach (var config in configs) + { + var sharedFolder = TryGetListedConfigurationFolder(vault, config); + if (sharedFolder == null) + { + continue; + } + + rows.Add(BuildConfigListJson(config, sharedFolder, verbose)); + } + + Console.WriteLine(Json.WriteFormatted(new Dictionary { ["configurations"] = rows })); + } + + private static void ListConfigurationsAsTable( + VaultOnline vault, + IEnumerable configs, + bool verbose) + { + var headers = new List + { + "UID", "Config Name", "Config Type", "Shared Folder", "Gateway UID", "Resource Record UIDs" + }; + if (verbose) + { + headers.Add("Fields"); + } + + var tab = new Tabulate(headers.Count); + tab.AddHeader(headers.ToArray()); + foreach (var config in configs) + { + var sharedFolder = TryGetListedConfigurationFolder(vault, config); + if (sharedFolder == null) + { + continue; + } + + tab.AddRow(BuildConfigTableRow(config, sharedFolder, verbose)); + } + + tab.Dump(); + } + + private static SharedFolder TryGetListedConfigurationFolder(VaultOnline vault, TypedRecord config) + { + var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); + if (sharedFolder == null) + { + PamVaultHelpers.WarnConfigurationNotInSharedFolder(config); + } + + return sharedFolder; + } + + private async Task ListSingleConfigurationAsync(VaultOnline vault, PamConfigOptions options, string configId) + { + var config = ResolveConfiguration(vault, configId); + if (config == null) + { + if (options.isFormatOutputJSON) + { + Console.WriteLine(Json.WriteFormatted(new Dictionary { ["error"] = $"Configuration {configId} not found" })); + } + else + { + Console.WriteLine($"Configuration \"{configId}\" not found."); + } + + return; + } + + if (options.isFormatOutputJSON) + { + Console.WriteLine(Json.WriteFormatted(BuildConfigDetailJson(vault, config, options.Verbose))); + return; + } + + var facade = new PamConfigurationFacade(config); + var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); + var tab = new Tabulate(2); + tab.AddRow("UID", config.Uid); + tab.AddRow("Name", config.Title); + tab.AddRow("Config Type", config.TypeName); + tab.AddRow("Shared Folder", sharedFolder != null ? $"{sharedFolder.Name} ({sharedFolder.Uid})" : ""); + tab.AddRow("Gateway UID", facade.ControllerUid); + tab.AddRow("Resource Record UIDs", string.Join(", ", facade.ResourceRef)); + foreach (var fieldRow in ExtractDisplayFields(config)) + { + tab.AddRow(fieldRow.Key, fieldRow.Value); + } + + tab.Dump(); + PamConfigTunnelingHelper.PrintTunnelingConfig(config.Uid); + await Task.CompletedTask; + } + + private async Task NewConfigurationAsync(PamConfigOptions options) + { + var vault = RequireVault(); + if (!await EnsurePluginAsync()) + { + return; + } + + if (!PamConfigTypes.TryResolveRecordType(options.Environment, out var recordType)) + { + throw new InvalidOperationException( + $"--environment parameter is required. Supported options: {PamConfigTypes.GetSupportedConfigTypes()}"); + } + + if (PamConfigTypes.IsComingSoonEnvironment(options.Environment, out var comingSoonName)) + { + Console.WriteLine($"Environment {comingSoonName} is not supported yet. It will be supported in a future release."); + return; + } + + if (string.IsNullOrWhiteSpace(options.Title)) + { + throw new InvalidOperationException("--title parameter is required"); + } + + PreResolveSharedFolderPath(options); + await vault.EnsurePamRecordTypesAsync(); + var editService = CreateEditService(vault); + editService.ClearWarnings(); + + var record = ConfigUtils.CreateConfigurationRecord(vault, recordType, options.Title); + editService.ApplyProperties(record, options, isEdit: false); + editService.VerifyRequired(record); + + var facade = new PamConfigurationFacade(record); + var moveDestinationUid = PamVaultHelpers.ResolvePamConfigurationFolderUid( + vault, options.SharedFolder, TryResolveFolderNode); + var sharedFolderUid = PamVaultHelpers.ResolvePamResourcesFolderUid(vault, facade.FolderUid) + ?? PamVaultHelpers.ResolvePamResourcesFolderUid(vault, moveDestinationUid); + if (!string.IsNullOrEmpty(sharedFolderUid)) + { + facade.FolderUid = sharedFolderUid; + } + + if (string.IsNullOrEmpty(sharedFolderUid) || string.IsNullOrEmpty(moveDestinationUid)) + { + if (string.IsNullOrWhiteSpace(options.SharedFolder)) + { + throw new InvalidOperationException("--shared-folder parameter is required to create a PAM configuration"); + } + + throw new InvalidOperationException( + $"Could not resolve shared folder \"{options.SharedFolder}\". " + + "Provide a shared folder UID, name, or path (e.g. PAM/TestFolder or /PAM/TestFolder). " + + "Run \"shared-folder list\" to see available folders."); + } + + if (string.IsNullOrEmpty(facade.ControllerUid) && !string.IsNullOrWhiteSpace(options.Gateway)) + { + editService.LogWarnings(); + } + + PamConfigFieldPlacement.EnsureSchemaFields(vault, record); + PamConfigFieldPlacement.RelocateCustomToFields(vault, record); + + await ConfigUtils.AddConfigurationRecordAsync(vault, record); + await ConfigUtils.EnsureConfigurationNetworkGraphAsync(Context.Enterprise.Auth, record.Uid); + await ConfigureTunnelingIfNeededAsync(record.Uid, options); + + await vault.SyncDown(); + await MoveRecordToSharedFolderAsync(vault, record, moveDestinationUid); + + if (!string.IsNullOrEmpty(facade.ControllerUid)) + { + await ConfigUtils.SetConfigurationGatewayAsync(Context.Enterprise.Auth, record.Uid, facade.ControllerUid); + } + + await vault.SyncDown(); + editService.LogWarnings(); + Console.WriteLine(record.Uid); + } + + private async Task EditConfigurationAsync(PamConfigOptions options) + { + var vault = RequireVault(); + if (!await EnsurePluginAsync(syncIfNeeded: false)) + { + return; + } + + if (string.IsNullOrWhiteSpace(options.Uid)) + { + throw new InvalidOperationException("Configuration UID or name is required for edit"); + } + + var configuration = ResolveConfiguration(vault, options.Uid); + if (configuration == null) + { + throw new InvalidOperationException($"PAM configuration \"{options.Uid}\" not found"); + } + + if (PamConfigTypes.IsComingSoonEnvironment(options.Environment, out var comingSoonName)) + { + Console.WriteLine($"Environment {comingSoonName} is not supported yet. It will be supported in a future release."); + return; + } + + await vault.EnsurePamRecordTypesAsync(); + + if (!string.IsNullOrWhiteSpace(options.Environment) + && PamConfigTypes.TryResolveRecordType(options.Environment, out var newType) + && !string.Equals(newType, configuration.TypeName, StringComparison.Ordinal)) + { + configuration.TypeName = newType; + vault.AdjustTypedRecord(configuration); + } + else + { + vault.AdjustTypedRecord(configuration); + } + + if (!string.IsNullOrWhiteSpace(options.Title)) + { + configuration.Title = options.Title.Trim(); + } + + var beforeEdit = new PamConfigurationFacade(configuration); + var origGatewayUid = beforeEdit.ControllerUid; + var origSharedFolderUid = beforeEdit.FolderUid; + var origAdminCredRef = beforeEdit.AdminCredentialRef; + + var editService = CreateEditService(vault); + editService.ClearWarnings(); + editService.ApplyProperties(configuration, options, isEdit: true); + editService.VerifyRequired(configuration); + PamConfigFieldPlacement.EnsureSchemaFields(vault, configuration); + PamConfigFieldPlacement.RelocateCustomToFields(vault, configuration); + await vault.UpdateRecord(configuration); + + var afterEdit = new PamConfigurationFacade(configuration); + if (!string.Equals(afterEdit.ControllerUid, origGatewayUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(afterEdit.ControllerUid)) + { + await ConfigUtils.SetConfigurationGatewayAsync( + Context.Enterprise.Auth, configuration.Uid, afterEdit.ControllerUid); + } + + if (!string.Equals(afterEdit.FolderUid, origSharedFolderUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(afterEdit.FolderUid)) + { + await MoveRecordToSharedFolderAsync(vault, configuration, afterEdit.FolderUid); + } + + if (HasTunnelingOptions(options) || !string.Equals(afterEdit.AdminCredentialRef, origAdminCredRef, StringComparison.Ordinal)) + { + await ConfigureTunnelingIfNeededAsync(configuration.Uid, options); + } + + editService.LogWarnings(); + await vault.ScheduleSyncDown(TimeSpan.FromMilliseconds(100)); + Console.WriteLine($"PAM configuration \"{configuration.Title}\" updated."); + } + + private async Task RemoveConfigurationAsync(PamConfigOptions options) + { + var vault = RequireVault(); + if (string.IsNullOrWhiteSpace(options.Uid)) + { + throw new InvalidOperationException("Configuration UID is required for remove"); + } + + var config = ResolveConfiguration(vault, options.Uid); + if (config == null) + { + throw new InvalidOperationException($"Configuration \"{options.Uid}\" not found"); + } + + await ConfigUtils.RemovePamConfigurationAsync(vault, config.Uid); + await vault.ScheduleSyncDown(TimeSpan.FromMilliseconds(100)); + Console.WriteLine($"PAM configuration \"{config.Title}\" removed."); + } + + private PamConfigEditService CreateEditService(VaultOnline vault) + { + return new PamConfigEditService(vault, TryGetVaultContext(), ResolveGateway); + } + + private VaultOnline RequireVault() + { + var vault = Context.GetVault(); + if (vault == null) + { + throw new VaultException("Vault is not initialized. Login to initialize the vault."); + } + + return vault; + } + + private TypedRecord ResolveConfiguration(VaultOnline vault, string identifier) + { + return TryResolvePamRecord(vault, identifier, PamRecordTypes.Configuration); + } + + private static async Task MoveRecordToSharedFolderAsync(VaultOnline vault, TypedRecord record, string destinationFolderUid) + { + vault.CacheKeeperRecord(record); + var sourceFolderUid = PamVaultHelpers.ResolveRecordSourceFolderUid(vault, record.Uid); + if (sourceFolderUid == null) + { + throw new VaultException("Cannot move PAM configuration: record is not initialized."); + } + + if (string.Equals(sourceFolderUid, destinationFolderUid, StringComparison.Ordinal)) + { + return; + } + + await vault.MoveRecordToFolder( + new RecordPath { RecordUid = record.Uid, FolderUid = sourceFolderUid }, + destinationFolderUid); + } + + private void PreResolveSharedFolderPath(PamConfigOptions options) + { + if (string.IsNullOrWhiteSpace(options.SharedFolder)) + { + return; + } + + var folderNode = TryResolveFolderNode(options.SharedFolder.Trim()); + if (folderNode != null) + { + options.SharedFolder = folderNode.FolderUid; + } + } + + private FolderNode TryResolveFolderNode(string path) + { + var vaultContext = TryGetVaultContext(); + if (vaultContext == null) + { + return null; + } + + return vaultContext.TryResolvePath(path, out var folderNode, out var remainder) + && string.IsNullOrEmpty(remainder) + && PamVaultHelpers.IsPamSharedFolderDestination(folderNode) + ? folderNode + : null; + } + + private async Task ConfigureTunnelingIfNeededAsync(string configUid, PamConfigOptions options) + { + if (!HasTunnelingOptions(options)) + { + return; + } + + await ConfigUtils.ConfigureTunnelingAsync( + Context.Enterprise.Auth, + configUid, + ConfigUtils.ParseTriState(options.Connections), + ConfigUtils.ParseTriState(options.Tunneling), + ConfigUtils.ParseTriState(options.Rotation), + ConfigUtils.ParseTriState(options.ConnectionsRecording), + ConfigUtils.ParseTriState(options.TypescriptRecording), + ConfigUtils.ParseTriState(options.RemoteBrowserIsolation), + ConfigUtils.ParseTriState(options.AiThreatDetection), + ConfigUtils.ParseTriState(options.AiTerminateSessionOnDetection)); + } + + private static bool HasTunnelingOptions(PamConfigOptions options) + { + return options.Connections != null || options.Tunneling != null || options.Rotation != null + || options.ConnectionsRecording != null || options.TypescriptRecording != null + || options.RemoteBrowserIsolation != null || options.AiThreatDetection != null + || options.AiTerminateSessionOnDetection != null; + } + + private static Dictionary BuildConfigListJson( + TypedRecord config, + SharedFolder sharedFolder, + bool verbose) + { + var facade = new PamConfigurationFacade(config); + var row = new Dictionary + { + ["uid"] = config.Uid, + ["config_name"] = config.Title, + ["config_type"] = config.TypeName, + ["shared_folder"] = new Dictionary { ["name"] = sharedFolder.Name, ["uid"] = sharedFolder.Uid }, + ["gateway_uid"] = facade.ControllerUid, + ["resource_record_uids"] = facade.ResourceRef, + }; + + if (verbose) + { + row["fields"] = ExtractDisplayFields(config).ToDictionary(x => x.Key, x => x.Value); + } + + return row; + } + + private static Dictionary BuildConfigDetailJson(VaultOnline vault, TypedRecord config, bool verbose) + { + var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); + var facade = new PamConfigurationFacade(config); + var row = new Dictionary + { + ["uid"] = config.Uid, + ["name"] = config.Title, + ["config_type"] = config.TypeName, + ["shared_folder"] = sharedFolder == null + ? null + : new Dictionary { ["name"] = sharedFolder.Name, ["uid"] = sharedFolder.Uid }, + ["gateway_uid"] = facade.ControllerUid, + ["resource_record_uids"] = facade.ResourceRef, + ["fields"] = ExtractDisplayFields(config).ToDictionary(x => x.Key, x => x.Value), + }; + + if (string.Equals(config.TypeName, "pamDomainConfiguration", StringComparison.Ordinal)) + { + row["domain_administrative_credential"] = facade.AdminCredentialRef; + } + + if (verbose) + { + row["allowed_settings"] = PamConfigTunnelingHelper.GetAllowedSettingsJson(config.Uid); + } + + return row; + } + + private static object[] BuildConfigTableRow( + TypedRecord config, + SharedFolder sharedFolder, + bool verbose) + { + var facade = new PamConfigurationFacade(config); + var row = new List + { + config.Uid, config.Title, config.TypeName, + $"{sharedFolder.Name} ({sharedFolder.Uid})", + facade.ControllerUid, string.Join(", ", facade.ResourceRef), + }; + + if (verbose) + { + row.Add(string.Join("; ", ExtractDisplayFields(config).Select(x => $"{x.Key}: {x.Value}"))); + } + + return row.ToArray(); + } + + private static IEnumerable> ExtractDisplayFields(TypedRecord config) + { + foreach (var field in config.Fields.Concat(config.Custom)) + { + if (field.FieldName is "pamResources" or "fileRef") + { + continue; + } + + if (string.Equals(field.FieldName, "schedule", StringComparison.Ordinal) + && !PamConfigScheduleHelper.IsDefaultRotationScheduleField(field)) + { + continue; + } + + var values = string.Equals(field.FieldName, "schedule", StringComparison.Ordinal) + ? PamConfigScheduleHelper.GetDisplayValues(field).ToList() + : field.GetTypedFieldInformation().ToList(); + if (values.Count == 0) + { + continue; + } + + yield return new KeyValuePair( + PamConfigScheduleHelper.GetPamFieldDisplayName(field), + string.Join(", ", values)); + } + } + } + + internal class PamConfigOptions : EnterpriseGenericOptions + { + [Value(0, Required = false, HelpText = "Command: list, new, edit, remove")] + public string Command { get; set; } + + [Value(1, Required = false, HelpText = "Configuration UID or name (list/edit/remove)")] + public string Uid { get; set; } + + [Option("environment", Required = false, HelpText = "PAM configuration type: local, aws, azure, gcp, domain, oci, github")] + public string Environment { get; set; } + + [Option('t', "title", Required = false, HelpText = "Title of the PAM configuration")] + public string Title { get; set; } + + [Option('g', "gateway", Required = false, HelpText = "Gateway UID or name")] + public string Gateway { get; set; } + + [Option("shared-folder", Required = false, HelpText = "Shared folder path or UID")] + public string SharedFolder { get; set; } + + [Option("schedule", Required = false, HelpText = "Default schedule CRON (e.g. 0 0 2 * * ?) or On-Demand")] + public string DefaultSchedule { get; set; } + + [Option("port-mapping", Required = false, HelpText = "Port mapping entry")] + public IList PortMapping { get; set; } + + [Option("identity-provider", Required = false, HelpText = "Identity Provider UID")] + public string IdentityProvider { get; set; } + + [Option('c', "connections", Required = false, HelpText = "Connections permissions: on, off, default")] + public string Connections { get; set; } + + [Option('u', "tunneling", Required = false, HelpText = "Tunneling permissions: on, off, default")] + public string Tunneling { get; set; } + + [Option('r', "rotation", Required = false, HelpText = "Rotation permissions: on, off, default")] + public string Rotation { get; set; } + + [Option("remote-browser-isolation", Required = false, HelpText = "Remote browser isolation: on, off, default")] + public string RemoteBrowserIsolation { get; set; } + + [Option("connections-recording", Required = false, HelpText = "Connection recording: on, off, default")] + public string ConnectionsRecording { get; set; } + + [Option("typescript-recording", Required = false, HelpText = "TypeScript recording: on, off, default")] + public string TypescriptRecording { get; set; } + + [Option("ai-threat-detection", Required = false, HelpText = "AI threat detection permissions: on, off, default")] + public string AiThreatDetection { get; set; } + + [Option("ai-terminate-session-on-detection", Required = false, HelpText = "AI session termination on threat detection: on, off, default")] + public string AiTerminateSessionOnDetection { get; set; } + + [Option("remove-resource-record", Required = false, HelpText = "Resource record UID to remove (edit)")] + public IList RemoveResourceRecords { get; set; } + + [Option("network-id", Required = false, HelpText = "Network ID (local/network)")] + public string NetworkId { get; set; } + + [Option("network-cidr", Required = false, HelpText = "Network CIDR (local/network)")] + public string NetworkCidr { get; set; } + + [Option("aws-id", Required = false, HelpText = "AWS ID")] + public string AwsId { get; set; } + + [Option("access-key-id", Required = false, HelpText = "AWS access key ID")] + public string AccessKeyId { get; set; } + + [Option("access-secret-key", Required = false, HelpText = "AWS access secret key")] + public string AccessSecretKey { get; set; } + + [Option("region-name", Required = false, HelpText = "AWS region name")] + public IList RegionNames { get; set; } + + [Option("azure-id", Required = false, HelpText = "Azure ID")] + public string AzureId { get; set; } + + [Option("client-id", Required = false, HelpText = "Azure client ID")] + public string ClientId { get; set; } + + [Option("client-secret", Required = false, HelpText = "Azure client secret")] + public string ClientSecret { get; set; } + + [Option("subscription-id", Required = false, HelpText = "Azure subscription ID")] + public string SubscriptionId { get; set; } + + [Option("tenant-id", Required = false, HelpText = "Azure tenant ID")] + public string TenantId { get; set; } + + [Option("resource-group", Required = false, HelpText = "Azure resource group")] + public IList ResourceGroups { get; set; } + + [Option("gcp-id", Required = false, HelpText = "GCP ID")] + public string GcpId { get; set; } + + [Option("service-account-key", Required = false, HelpText = "GCP service account key JSON")] + public string ServiceAccountKey { get; set; } + + [Option("google-admin-email", Required = false, HelpText = "Google Workspace admin email")] + public string GoogleAdminEmail { get; set; } + + [Option("gcp-region", Required = false, HelpText = "GCP region name")] + public IList GcpRegionNames { get; set; } + + [Option("domain-id", Required = false, HelpText = "Domain ID")] + public string DomainId { get; set; } + + [Option("domain-hostname", Required = false, HelpText = "Domain hostname")] + public string DomainHostname { get; set; } + + [Option("domain-port", Required = false, HelpText = "Domain port")] + public string DomainPort { get; set; } + + [Option("domain-use-ssl", Required = false, HelpText = "Domain use SSL: true, false")] + public string DomainUseSsl { get; set; } + + [Option("domain-scan-dc-cidr", Required = false, HelpText = "Domain scan DC CIDR: true, false")] + public string DomainScanDcCidr { get; set; } + + [Option("domain-network-cidr", Required = false, HelpText = "Domain network CIDR")] + public string DomainNetworkCidr { get; set; } + + [Option("domain-admin", Required = false, HelpText = "Domain administrative credential")] + public string DomainAdministrativeCredential { get; set; } + + [Option("domain-user-match", Required = false, HelpText = "Domain user match filter")] + public string DomainUserMatch { get; set; } + + [Option("force-domain-admin", Required = false, HelpText = "Treat domain-admin as raw UID")] + public bool ForceDomainAdmin { get; set; } + + [Option("oci-id", Required = false, HelpText = "OCI ID")] + public string OciId { get; set; } + + [Option("oci-admin-id", Required = false, HelpText = "OCI admin ID")] + public string OciAdminId { get; set; } + + [Option("oci-admin-public-key", Required = false, HelpText = "OCI admin public key")] + public string OciAdminPublicKey { get; set; } + + [Option("oci-admin-private-key", Required = false, HelpText = "OCI admin private key")] + public string OciAdminPrivateKey { get; set; } + + [Option("oci-tenancy", Required = false, HelpText = "OCI tenancy")] + public string OciTenancy { get; set; } + + [Option("oci-region", Required = false, HelpText = "OCI region")] + public string OciRegion { get; set; } + + [Option('v', "verbose", Required = false, HelpText = "Verbose output")] + public bool Verbose { get; set; } + + [Option("format", Required = false, Default = "table", HelpText = "Output format: table, json")] + public string Format { get; set; } + + internal bool isFormatOutputJSON => string.Equals(Format, "json", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Commander/PAM/PamConfigEditService.cs b/Commander/PAM/PamConfigEditService.cs new file mode 100644 index 00000000..20a66770 --- /dev/null +++ b/Commander/PAM/PamConfigEditService.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Commander; +using KeeperSecurity.Plugins.PAM; +using KeeperSecurity.Utils; +using KeeperSecurity.Vault; + +namespace Commander.PAM +{ + internal sealed class PamConfigEditService + { + private readonly VaultOnline _vault; + private readonly VaultContext _vaultContext; + private readonly Func _resolveGateway; + private readonly List _warnings = new(); + + public PamConfigEditService( + VaultOnline vault, + VaultContext vaultContext, + Func resolveGateway) + { + _vault = vault ?? throw new ArgumentNullException(nameof(vault)); + _vaultContext = vaultContext; + _resolveGateway = resolveGateway; + } + + public void ClearWarnings() => _warnings.Clear(); + + public void LogWarnings() + { + foreach (var warning in _warnings) + { + Console.WriteLine($"Warning: {warning}"); + } + } + + public void ApplyProperties(TypedRecord record, PamConfigOptions options, bool isEdit) + { + PamConfigFieldPlacement.EnsureSchemaFields(_vault, record); + ApplyPamResources(record, options, isEdit); + var properties = BuildExtraProperties(record, options, isEdit); + PamConfigFieldAssigner.AssignProperties(_vault, record, properties); + PamConfigScheduleHelper.ApplyDefaultRotationSchedule(record, options, isEdit); + PamConfigFieldPlacement.RelocateCustomToFields(_vault, record); + } + + public void VerifyRequired(TypedRecord record) + { + foreach (var field in record.Fields) + { + if (!field.Required || field.Count > 0) + { + continue; + } + + if (string.Equals(field.FieldName, "schedule", StringComparison.Ordinal)) + { + PamConfigScheduleHelper.EnsureDefaultRotationScheduleIfEmpty(record); + } + else + { + _warnings.Add($"Empty required field: \"{PamConfigScheduleHelper.GetPamFieldDisplayName(field)}\""); + } + } + + foreach (var custom in record.Custom) + { + custom.Required = false; + } + } + + private void ApplyPamResources(TypedRecord record, PamConfigOptions options, bool isEdit) + { + var facade = new PamConfigurationFacade(record); + + if (!string.IsNullOrWhiteSpace(options.Gateway)) + { + var gateway = _resolveGateway?.Invoke(options.Gateway.Trim()); + if (gateway != null) + { + facade.ControllerUid = gateway.Uid; + } + else if (!isEdit) + { + _warnings.Add($"Gateway \"{options.Gateway}\" not found."); + } + } + + if (!string.IsNullOrWhiteSpace(options.SharedFolder)) + { + var folderUid = ResolveSharedFolderUid(options.SharedFolder.Trim()); + if (!string.IsNullOrEmpty(folderUid)) + { + facade.FolderUid = PamVaultHelpers.ResolvePamResourcesFolderUid(_vault, folderUid) ?? folderUid; + } + } + else if (isEdit && string.IsNullOrEmpty(facade.FolderUid)) + { + throw new InvalidOperationException("Shared Folder not found"); + } + + if (options.RemoveResourceRecords != null && options.RemoveResourceRecords.Count > 0) + { + var toRemove = new List(); + foreach (var removeRef in options.RemoveResourceRecords) + { + if (string.IsNullOrWhiteSpace(removeRef)) + { + continue; + } + + var resolved = PamVaultHelpers.ResolveRecord(_vault, removeRef.Trim(), PamRecordTypes.Rotation); + if (resolved != null) + { + toRemove.Add(resolved.Uid); + continue; + } + + var titleMatches = _vault.KeeperRecords + .OfType() + .Where(x => PamRecordTypes.Rotation.Contains(x.TypeName ?? "")) + .Where(x => string.Equals(x.Title, removeRef.Trim(), StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (titleMatches.Count == 1) + { + toRemove.Add(titleMatches[0].Uid); + } + else + { + _warnings.Add($"Failed to find PAM record: {removeRef}"); + } + } + + facade.RemoveResourceRefs(toRemove); + } + } + + private List BuildExtraProperties(TypedRecord record, PamConfigOptions options, bool isEdit) + { + var properties = new List(); + + if (options.PortMapping != null && options.PortMapping.Count > 0) + { + properties.Add($"multiline.portMapping={string.Join("\n", options.PortMapping)}"); + } + + if (!string.IsNullOrWhiteSpace(options.IdentityProvider)) + { + properties.Add($"text.identityProviderUid={options.IdentityProvider.Trim()}"); + } + + switch (record.TypeName) + { + case "pamNetworkConfiguration": + AddTextProperty(properties, "text.networkId", options.NetworkId, isEdit); + AddTextProperty(properties, "text.networkCIDR", options.NetworkCidr, isEdit); + break; + case "pamAwsConfiguration": + AddTextProperty(properties, "text.awsId", options.AwsId, isEdit); + AddTextProperty(properties, "secret.accessKeyId", options.AccessKeyId, isEdit); + AddTextProperty(properties, "secret.accessSecretKey", options.AccessSecretKey, isEdit); + if (options.RegionNames != null && options.RegionNames.Count > 0) + { + properties.Add($"multiline.regionNames={string.Join("\n", options.RegionNames)}"); + } + + break; + case "pamGcpConfiguration": + AddTextProperty(properties, "text.pamGcpId", options.GcpId, isEdit); + AddTextProperty(properties, "json.pamServiceAccountKey", options.ServiceAccountKey, isEdit); + AddTextProperty(properties, "email.pamGoogleAdminEmail", options.GoogleAdminEmail, isEdit); + if (options.GcpRegionNames != null && options.GcpRegionNames.Count > 0) + { + properties.Add($"multiline.pamGcpRegionName={string.Join("\n", options.GcpRegionNames)}"); + } + + break; + case "pamAzureConfiguration": + AddTextProperty(properties, "text.azureId", options.AzureId, isEdit); + AddTextProperty(properties, "secret.clientId", options.ClientId, isEdit); + AddTextProperty(properties, "secret.clientSecret", options.ClientSecret, isEdit); + AddTextProperty(properties, "secret.subscriptionId", options.SubscriptionId, isEdit); + AddTextProperty(properties, "secret.tenantId", options.TenantId, isEdit); + if (options.ResourceGroups != null && options.ResourceGroups.Count > 0) + { + properties.Add($"multiline.resourceGroups={string.Join("\n", options.ResourceGroups)}"); + } + + break; + case "pamDomainConfiguration": + ApplyDomainProperties(record, options, isEdit, properties); + break; + case "pamOciConfiguration": + AddTextProperty(properties, "text.pamOciId", options.OciId, isEdit); + AddTextProperty(properties, "secret.adminOcid", options.OciAdminId, isEdit); + AddTextProperty(properties, "secret.adminPublicKey", options.OciAdminPublicKey, isEdit); + AddTextProperty(properties, "secret.adminPrivateKey", options.OciAdminPrivateKey, isEdit); + AddTextProperty(properties, "text.tenancyOci", options.OciTenancy, isEdit); + AddTextProperty(properties, "text.regionOci", options.OciRegion, isEdit); + break; + } + + return properties; + } + + private void ApplyDomainProperties( + TypedRecord record, + PamConfigOptions options, + bool isEdit, + List properties) + { + ApplyDomainTextProperties(options, isEdit, properties); + ApplyDomainHostnameProperty(record, options, isEdit, properties); + ApplyDomainCheckboxProperties(record, options); + ApplyDomainAdminCredential(record, options); + } + + private static void ApplyDomainTextProperties( + PamConfigOptions options, + bool isEdit, + List properties) + { + if (DomainKwargSupplied(options.DomainId, isEdit)) + { + properties.Add($"text.pamDomainId={options.DomainId ?? ""}"); + } + + if (DomainKwargSupplied(options.DomainNetworkCidr, isEdit)) + { + properties.Add($"text.networkCIDR={options.DomainNetworkCidr ?? ""}"); + } + + if (DomainKwargSupplied(options.DomainUserMatch, isEdit)) + { + properties.Add($"text.userMatch={options.DomainUserMatch ?? ""}"); + } + } + + private static void ApplyDomainHostnameProperty( + TypedRecord record, + PamConfigOptions options, + bool isEdit, + List properties) + { + string host; + string port; + + if (isEdit) + { + if (options.DomainHostname == null && options.DomainPort == null) + { + return; + } + + var existing = PamConfigFieldAssigner.GetPamHostname(record); + host = options.DomainHostname != null + ? options.DomainHostname.Trim() + : existing?.HostName ?? ""; + port = options.DomainPort != null + ? options.DomainPort.Trim() + : existing?.Port ?? ""; + } + else + { + host = options.DomainHostname?.Trim() ?? ""; + port = options.DomainPort?.Trim() ?? ""; + if (string.IsNullOrEmpty(host) && string.IsNullOrEmpty(port)) + { + return; + } + } + + properties.Add(BuildPamHostnameProperty(host, port)); + } + + private static string BuildPamHostnameProperty(string host, string port) + { + if (string.IsNullOrEmpty(host) && string.IsNullOrEmpty(port)) + { + return "f.pamHostname="; + } + + return $"f.pamHostname=$JSON:{{\"hostName\":\"{EscapeJson(host)}\",\"port\":\"{EscapeJson(port)}\"}}"; + } + + private static void ApplyDomainCheckboxProperties(TypedRecord record, PamConfigOptions options) + { + var useSsl = ParseTriBool(options.DomainUseSsl); + if (useSsl.HasValue) + { + PamConfigFieldAssigner.SetCheckboxField(record, "useSSL", useSsl); + } + + var scanDc = ParseTriBool(options.DomainScanDcCidr); + if (scanDc.HasValue) + { + PamConfigFieldAssigner.SetCheckboxField(record, "scanDCCIDR", scanDc); + } + } + + private void ApplyDomainAdminCredential(TypedRecord record, PamConfigOptions options) + { + if (string.IsNullOrWhiteSpace(options.DomainAdministrativeCredential)) + { + return; + } + + var dac = options.DomainAdministrativeCredential.Trim(); + if (options.ForceDomainAdmin) + { + if (!Regex.IsMatch(dac, "^[A-Za-z0-9\\-_]{22}$")) + { + _warnings.Add($"Invalid Domain Admin User UID: \"{dac}\" (skipped)"); + return; + } + } + else + { + var adminRecord = PamVaultHelpers.ResolveRecord(_vault, dac, new[] { "pamUser" }); + if (adminRecord == null) + { + _warnings.Add($"Domain Admin User UID: \"{dac}\" not found (skipped)."); + return; + } + + dac = adminRecord.Uid; + } + + new PamConfigurationFacade(record).AdminCredentialRef = dac; + } + + private string ResolveSharedFolderUid(string pathOrUid) + { + return PamVaultHelpers.ResolvePamConfigurationFolderUid(_vault, pathOrUid, TryResolveFolderNode); + } + + private FolderNode TryResolveFolderNode(string path) + { + if (_vaultContext == null) + { + return null; + } + + return _vaultContext.TryResolvePath(path, out var folderNode, out var remainder) + && string.IsNullOrEmpty(remainder) + && PamVaultHelpers.IsPamSharedFolderDestination(folderNode) + ? folderNode + : null; + } + + private static void AddTextProperty(List properties, string fieldSpec, string value, bool isEdit) + { + if (isEdit) + { + if (value != null) + { + var parts = fieldSpec.Split('.'); + properties.Add($"{parts[0]}.{parts[1]}={value}"); + } + } + else if (!string.IsNullOrWhiteSpace(value)) + { + var parts = fieldSpec.Split('.'); + properties.Add($"{parts[0]}.{parts[1]}={value.Trim()}"); + } + } + + private static bool DomainKwargSupplied(string value, bool isEdit) + { + return isEdit ? value != null : !string.IsNullOrWhiteSpace(value); + } + + private static bool? ParseTriBool(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim().ToLowerInvariant() switch + { + "true" => true, + "false" => false, + _ => null, + }; + } + + private static string EscapeJson(string value) + { + return (value ?? "").Replace("\\", "\\\\").Replace("\"", "\\\""); + } + } +} diff --git a/Commander/PAM/PamConfigFieldAssigner.cs b/Commander/PAM/PamConfigFieldAssigner.cs new file mode 100644 index 00000000..4edf0f8b --- /dev/null +++ b/Commander/PAM/PamConfigFieldAssigner.cs @@ -0,0 +1,498 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Commander; +using KeeperSecurity.Utils; +using KeeperSecurity.Vault; +using ZeroDep; + +namespace Commander.PAM +{ + /// + /// Ensures PAM configuration field values are stored in fields[] (Web Vault reads fields[], not custom[]). + /// + internal static class PamConfigFieldPlacement + { + public static void EnsureSchemaFields(VaultData vault, TypedRecord record) + { + if (vault == null || record == null) + { + return; + } + + vault.AdjustTypedRecord(record); + } + + /// + /// Moves schema-matching values from custom[] into empty fields[] slots before save. + /// + public static void RelocateCustomToFields(VaultData vault, TypedRecord record) + { + if (vault == null || record == null || record.Custom.Count == 0) + { + return; + } + + EnsureSchemaFields(vault, record); + var schemaKeys = GetSchemaKeys(vault, record.TypeName); + if (schemaKeys == null || schemaKeys.Count == 0) + { + return; + } + + var relocated = new List(); + foreach (var customField in record.Custom.ToList()) + { + if (customField.Count == 0) + { + continue; + } + + var key = customField.GetTypedFieldName(); + if (!schemaKeys.Contains(key)) + { + continue; + } + + if (!TryGetFieldSlot(record, customField.FieldName, customField.FieldLabel, out var schemaField)) + { + continue; + } + + if (FieldHasValue(schemaField)) + { + continue; + } + + CopyFieldValues(customField, schemaField); + relocated.Add(customField); + } + + foreach (var field in relocated) + { + record.Custom.Remove(field); + } + } + + private static HashSet GetSchemaKeys(VaultData vault, string typeName) + { + if (!vault.TryGetRecordTypeByName(typeName, out var recordType) || recordType.Fields == null) + { + return null; + } + + return new HashSet( + recordType.Fields.Select(x => x.GetTypedFieldName()), + StringComparer.OrdinalIgnoreCase); + } + + private static bool FieldHasValue(ITypedField field) + { + if (field == null || field.Count == 0) + { + return false; + } + + for (var i = 0; i < field.Count; i++) + { + var value = field.GetValueAt(i); + if (value == null) + { + continue; + } + + if (value is string s) + { + if (!string.IsNullOrWhiteSpace(s)) + { + return true; + } + } + else if (value is FieldSchedule schedule) + { + if (!string.IsNullOrWhiteSpace(schedule.Type)) + { + return true; + } + } + else if (value is FieldTypeHost host) + { + if (!string.IsNullOrWhiteSpace(host.HostName) || !string.IsNullOrWhiteSpace(host.Port)) + { + return true; + } + } + else if (value is bool b) + { + return true; + } + else + { + return true; + } + } + + return false; + } + + private static void CopyFieldValues(ITypedField source, ITypedField destination) + { + while (destination.Count > 0) + { + destination.DeleteValueAt(0); + } + + for (var i = 0; i < source.Count; i++) + { + if (i > 0) + { + ((ITypedField)destination).AppendValue(); + } + else if (destination.Count == 0) + { + ((ITypedField)destination).AppendValue(); + } + + var value = source.GetValueAt(i); + if (value is FieldSchedule schedule) + { + destination.SetValueAt(i, CloneSchedule(schedule)); + } + else if (value is FieldTypeHost host) + { + destination.SetValueAt(i, new FieldTypeHost { HostName = host.HostName, Port = host.Port }); + } + else + { + destination.SetValueAt(i, value); + } + } + } + + private static FieldSchedule CloneSchedule(FieldSchedule schedule) + { + return new FieldSchedule + { + Type = schedule.Type, + Cron = schedule.Cron, + TimeZone = schedule.TimeZone, + Time = schedule.Time, + Weekday = schedule.Weekday, + Month = schedule.Month, + MonthDay = schedule.MonthDay, + IntervalCount = schedule.IntervalCount, + EndDate = schedule.EndDate, + Occurrences = schedule.Occurrences, + Occurrence = schedule.Occurrence, + }; + } + + public static bool TryGetFieldSlot( + TypedRecord record, + string fieldType, + string fieldLabel, + out ITypedField field) + { + field = null; + if (record == null) + { + return false; + } + + if (record.FindTypedField(fieldType, fieldLabel, out field) && record.Fields.Contains(field)) + { + return true; + } + + field = record.Fields.FirstOrDefault(f => + string.Equals(f.FieldName, fieldType, StringComparison.OrdinalIgnoreCase) + && string.Equals(f.FieldLabel ?? "", fieldLabel ?? "", StringComparison.OrdinalIgnoreCase)); + + if (field != null) + { + return true; + } + + if (!string.IsNullOrEmpty(fieldLabel)) + { + var schemaKey = new RecordTypeField(fieldType, fieldLabel).GetTypedFieldName(); + field = record.Fields.FirstOrDefault(f => + string.Equals(f.GetTypedFieldName(), schemaKey, StringComparison.OrdinalIgnoreCase)); + if (field != null) + { + return true; + } + + field = record.Fields.FirstOrDefault(f => + string.Equals(f.FieldLabel ?? "", fieldLabel, StringComparison.OrdinalIgnoreCase)); + if (field != null) + { + return true; + } + } + + if (string.Equals(fieldType, "pamHostname", StringComparison.OrdinalIgnoreCase) + || string.Equals(fieldLabel, "pamHostname", StringComparison.OrdinalIgnoreCase)) + { + field = record.Fields.FirstOrDefault(f => + string.Equals(f.FieldName, "pamHostname", StringComparison.OrdinalIgnoreCase)); + if (field != null) + { + return true; + } + } + + return false; + } + } + + /// + /// Applies type.label=value strings to typed record schema slots in fields[]. + /// + internal static class PamConfigFieldAssigner + { + private static readonly Regex PropertyPattern = new( + @"^([^\[\.]+)(\.[^\[]+)?(\[.*\])?\s*=\s*(.*)$", + RegexOptions.Compiled); + + public static void AssignProperties(VaultData vault, TypedRecord record, IEnumerable properties) + { + if (record == null || properties == null) + { + return; + } + + PamConfigFieldPlacement.EnsureSchemaFields(vault, record); + + foreach (var property in properties) + { + if (string.IsNullOrWhiteSpace(property)) + { + continue; + } + + var trimmed = property.Trim(); + if (trimmed.StartsWith("schedule.", StringComparison.OrdinalIgnoreCase)) + { + ApplyScheduleProperty(record, trimmed); + continue; + } + + if (trimmed.StartsWith("f.", StringComparison.OrdinalIgnoreCase)) + { + ApplyCompositeProperty(record, trimmed.Substring(2)); + continue; + } + + var parsed = ParseProperty(trimmed); + if (parsed == null) + { + continue; + } + + SetScalarField(record, parsed.FieldName, parsed.FieldLabel, parsed.Value); + } + + PamConfigFieldPlacement.RelocateCustomToFields(vault, record); + } + + private static CmdLineRecordField ParseProperty(string property) + { + var match = PropertyPattern.Match(property); + if (!match.Success || match.Groups.Count < 5) + { + return null; + } + + return new CmdLineRecordField + { + FieldName = match.Groups[1].Value.Trim(), + FieldLabel = match.Groups[2].Value.Trim('.').Trim(), + FieldIndex = match.Groups[3].Value.Trim('[', ']').Trim(), + Value = Unquote(match.Groups[4].Value.Trim()), + }; + } + + private static string Unquote(string value) + { + if (value.Length >= 2 && value.StartsWith("\"") && value.EndsWith("\"")) + { + return value.Trim('"').Replace("\\\"", "\""); + } + + return value; + } + + private static void ApplyScheduleProperty(TypedRecord record, string property) + { + var eq = property.IndexOf('='); + if (eq < 0) + { + return; + } + + var value = property.Substring(eq + 1).Trim(); + if (string.Equals(value, "On-Demand", StringComparison.OrdinalIgnoreCase)) + { + PamConfigScheduleHelper.SetOnDemandSchedule(record); + return; + } + + if (value.StartsWith("$JSON:", StringComparison.OrdinalIgnoreCase)) + { + PamConfigScheduleHelper.SetScheduleFromJson(record, value.Substring(6)); + } + } + + private static void ApplyCompositeProperty(TypedRecord record, string property) + { + var eq = property.IndexOf('='); + if (eq < 0) + { + return; + } + + var fieldName = property.Substring(0, eq).Trim(); + var value = property.Substring(eq + 1).Trim(); + + if (!string.Equals(fieldName, "pamHostname", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (string.IsNullOrEmpty(value)) + { + ClearField(record, "pamHostname", null); + return; + } + + if (value.StartsWith("$JSON:", StringComparison.OrdinalIgnoreCase)) + { + var json = value.Substring(6); + var dict = Json.Deserialize>(json); + var host = dict != null && dict.TryGetValue("hostName", out var hn) ? Convert.ToString(hn) ?? "" : ""; + var port = dict != null && dict.TryGetValue("port", out var pt) ? Convert.ToString(pt) ?? "" : ""; + SetPamHostname(record, host, port); + } + } + + public static void SetPamHostname(TypedRecord record, string hostName, string port) + { + if (!PamConfigFieldPlacement.TryGetFieldSlot(record, "pamHostname", null, out var field)) + { + throw new InvalidOperationException("Could not find pamHostname field slot in record schema."); + } + + if (field.Count == 0) + { + ((ITypedField)field).AppendValue(); + } + + if (field.GetValueAt(0) is FieldTypeHost host) + { + host.HostName = hostName ?? ""; + host.Port = port ?? ""; + } + else + { + field.SetValueAt(0, new FieldTypeHost { HostName = hostName ?? "", Port = port ?? "" }); + } + } + + public static void SetCheckboxField(TypedRecord record, string label, bool? value) + { + if (value == null) + { + return; + } + + if (!PamConfigFieldPlacement.TryGetFieldSlot(record, "checkbox", label, out var field) + || field is not TypedField boolField) + { + return; + } + + if (boolField.Count == 0) + { + ((ITypedField)boolField).AppendValue(); + } + + boolField.Values[0] = value.Value; + } + + public static FieldTypeHost GetPamHostname(TypedRecord record) + { + if (PamConfigFieldPlacement.TryGetFieldSlot(record, "pamHostname", null, out var field) + && field.Count > 0 + && field.GetValueAt(0) is FieldTypeHost host) + { + return host; + } + + return null; + } + + public static void ClearField(TypedRecord record, string fieldName, string fieldLabel) + { + if (PamConfigFieldPlacement.TryGetFieldSlot(record, fieldName, fieldLabel, out var field)) + { + while (field.Count > 0) + { + field.DeleteValueAt(0); + } + } + } + + private static void SetScalarField(TypedRecord record, string fieldType, string fieldLabel, string value) + { + if (!PamConfigFieldPlacement.TryGetFieldSlot(record, fieldType, fieldLabel, out var field)) + { + try + { + field = VaultDataExtensions.CreateTypedField(fieldType, fieldLabel); + record.Fields.Add(field); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Could not find field slot \"{fieldType}.{fieldLabel}\" in record schema. " + + "Ensure record types are synced before creating PAM configurations.", + ex); + } + } + + if (string.IsNullOrEmpty(value)) + { + while (field.Count > 0) + { + field.DeleteValueAt(0); + } + + return; + } + + if (field.Count == 0) + { + ((ITypedField)field).AppendValue(); + } + + if (field is TypedField stringField) + { + stringField.Values[0] = value; + return; + } + + if (field is TypedField boolField) + { + boolField.Values[0] = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + return; + } + + if (field.GetValueAt(0) is IFieldTypeSerialize serializer) + { + serializer.SetValueAsString(value); + } + } + } +} diff --git a/Commander/PAM/PamConfigScheduleHelper.cs b/Commander/PAM/PamConfigScheduleHelper.cs new file mode 100644 index 00000000..a7d5448b --- /dev/null +++ b/Commander/PAM/PamConfigScheduleHelper.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.Text; +using KeeperSecurity.Plugins.PAM; +using KeeperSecurity.Utils; +using KeeperSecurity.Vault; +using ZeroDep; + +namespace Commander.PAM +{ + internal static class PamConfigScheduleHelper + { + private const string ScheduleFieldType = "schedule"; + private const string DefaultRotationScheduleLabel = "defaultRotationSchedule"; + private const string DefaultTimeZone = "Etc/UTC"; + + public static void ApplyDefaultRotationSchedule(TypedRecord record, PamConfigOptions options, bool isEdit) + { + // Omitted --schedule: leave existing value on edit; default On-Demand on create. + if (options.DefaultSchedule == null) + { + if (!isEdit) + { + SetOnDemandSchedule(record); + } + + return; + } + + var cron = options.DefaultSchedule.Trim(); + if (string.IsNullOrEmpty(cron) || IsOnDemandScheduleValue(cron)) + { + SetOnDemandSchedule(record); + return; + } + + cron = NormalizeRotationCronForStorage(cron); + var (isValid, message) = RotationUtils.ValidateCronExpression(cron, forRotation: true); + if (!isValid) + { + throw new InvalidOperationException($"Invalid CRON \"{cron}\" Error: {message}"); + } + + SetCronSchedule(record, cron); + } + + private static bool IsOnDemandScheduleValue(string value) + { + return string.Equals(value, "On-Demand", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "ON_DEMAND", StringComparison.OrdinalIgnoreCase); + } + + public static void EnsureDefaultRotationScheduleIfEmpty(TypedRecord record) + { + if (!TryGetDefaultRotationScheduleField(record, out var scheduleField)) + { + return; + } + + if (scheduleField.Count > 0 && scheduleField.Values[0] != null) + { + return; + } + + if (scheduleField.Count == 0) + { + ((ITypedField)scheduleField).AppendValue(); + } + + scheduleField.Values[0] = new FieldSchedule { Type = "ON_DEMAND" }; + } + + public static void SetOnDemandSchedule(TypedRecord record) + { + var scheduleField = EnsureDefaultRotationScheduleField(record); + if (scheduleField.Count == 0) + { + ((ITypedField)scheduleField).AppendValue(); + } + + scheduleField.Values[0] = new FieldSchedule { Type = "ON_DEMAND" }; + } + + public static void SetCronSchedule(TypedRecord record, string cron) + { + var scheduleField = EnsureDefaultRotationScheduleField(record); + if (scheduleField.Count == 0) + { + ((ITypedField)scheduleField).AppendValue(); + } + + scheduleField.Values[0] = new FieldSchedule + { + Type = "CRON", + Cron = NormalizeRotationCronForStorage(cron), + TimeZone = DefaultTimeZone, + }; + } + + public static void SetScheduleFromJson(TypedRecord record, string json) + { + var schedule = Json.Deserialize(json); + if (schedule == null) + { + return; + } + + var scheduleField = EnsureDefaultRotationScheduleField(record); + if (scheduleField.Count == 0) + { + ((ITypedField)scheduleField).AppendValue(); + } + + scheduleField.Values[0] = NormalizeSchedule(schedule); + } + + public static IEnumerable GetDisplayValues(ITypedField field) + { + if (!string.Equals(field.FieldName, ScheduleFieldType, StringComparison.Ordinal)) + { + return field.GetTypedFieldInformation(); + } + + var values = new List(); + for (var i = 0; i < field.Count; i++) + { + if (field.GetValueAt(i) is FieldSchedule schedule) + { + var exported = ExportScheduleField(schedule); + if (!string.IsNullOrEmpty(exported)) + { + values.Add(exported); + } + } + } + + return values; + } + + public static string GetPamFieldDisplayName(IRecordTypeField field) + { + var type = field.FieldName ?? ""; + var label = field.FieldLabel ?? ""; + if (!string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(label)) + { + return string.Equals(field.FieldName, ScheduleFieldType, StringComparison.Ordinal) + ? "Default Schedule" + : $"({type}).{label}"; + } + + if (!string.IsNullOrEmpty(type)) + { + return string.Equals(type, ScheduleFieldType, StringComparison.Ordinal) ? "Default Schedule" : $"({type})"; + } + + return label; + } + + public static bool IsDefaultRotationScheduleField(ITypedField field) + { + return string.Equals(field.FieldName, ScheduleFieldType, StringComparison.Ordinal) + && string.Equals(field.FieldLabel, DefaultRotationScheduleLabel, StringComparison.Ordinal); + } + + public static string ExportScheduleField(FieldSchedule schedule) + { + if (schedule == null || string.IsNullOrWhiteSpace(schedule.Type)) + { + return null; + } + + switch (schedule.Type.Trim().ToUpperInvariant()) + { + case "CRON": + return ExportCronScheduleField(schedule.Cron); + case "ON_DEMAND": + return null; + case "RUN_ONCE": + return FormatRunOnce(schedule); + case "DAILY": + case "WEEKLY": + case "MONTHLY_BY_DAY": + case "MONTHLY_BY_WEEKDAY": + case "YEARLY": + return FormatRecurringSchedule(schedule); + default: + return schedule.Type; + } + } + + private static string ExportCronScheduleField(string cron) + { + if (string.IsNullOrWhiteSpace(cron)) + { + return null; + } + + return cron.Trim(); + } + + private static string NormalizeRotationCronForStorage(string cron) + { + return cron?.Trim(); + } + + private static FieldSchedule NormalizeSchedule(FieldSchedule source) + { + if (source == null || string.IsNullOrWhiteSpace(source.Type)) + { + return source; + } + + switch (source.Type.Trim().ToUpperInvariant()) + { + case "CRON": + return new FieldSchedule + { + Type = "CRON", + Cron = source.Cron?.Trim(), + TimeZone = string.IsNullOrWhiteSpace(source.TimeZone) ? DefaultTimeZone : source.TimeZone.Trim(), + EndDate = source.EndDate?.Trim(), + Occurrences = source.Occurrences, + }; + case "ON_DEMAND": + return new FieldSchedule { Type = "ON_DEMAND" }; + default: + return source; + } + } + + private static string FormatRunOnce(FieldSchedule schedule) + { + if (string.IsNullOrWhiteSpace(schedule.Time)) + { + return "RUN_ONCE"; + } + + return string.IsNullOrWhiteSpace(schedule.TimeZone) + ? schedule.Time + : $"{schedule.Time} ({schedule.TimeZone})"; + } + + private static string FormatRecurringSchedule(FieldSchedule schedule) + { + var parts = new List { schedule.Type }; + if (!string.IsNullOrWhiteSpace(schedule.Time)) + { + parts.Add($"time={schedule.Time}"); + } + + if (!string.IsNullOrWhiteSpace(schedule.TimeZone)) + { + parts.Add($"tz={schedule.TimeZone}"); + } + + if (!string.IsNullOrWhiteSpace(schedule.Weekday)) + { + parts.Add($"weekday={schedule.Weekday}"); + } + + if (!string.IsNullOrWhiteSpace(schedule.Month)) + { + parts.Add($"month={schedule.Month}"); + } + + if (schedule.MonthDay.HasValue) + { + parts.Add($"monthDay={schedule.MonthDay.Value}"); + } + + if (!string.IsNullOrWhiteSpace(schedule.Occurrence)) + { + parts.Add($"occurrence={schedule.Occurrence}"); + } + + if (schedule.IntervalCount.HasValue) + { + parts.Add($"intervalCount={schedule.IntervalCount.Value}"); + } + + if (!string.IsNullOrWhiteSpace(schedule.EndDate)) + { + parts.Add($"endDate={schedule.EndDate}"); + } + + if (schedule.Occurrences.HasValue) + { + parts.Add($"occurrences={schedule.Occurrences.Value}"); + } + + return string.Join(", ", parts); + } + + private static TypedField EnsureDefaultRotationScheduleField(TypedRecord record) + { + if (TryGetDefaultRotationScheduleField(record, out var scheduleField)) + { + return scheduleField; + } + + throw new InvalidOperationException( + "Could not find defaultRotationSchedule field slot in record schema. " + + "Ensure record types are synced before creating PAM configurations."); + } + + private static bool TryGetDefaultRotationScheduleField( + TypedRecord record, + out TypedField scheduleField) + { + scheduleField = null; + if (!PamConfigFieldPlacement.TryGetFieldSlot(record, ScheduleFieldType, DefaultRotationScheduleLabel, out var field)) + { + return false; + } + + scheduleField = field as TypedField; + return scheduleField != null; + } + } +} diff --git a/Commander/PAM/PamConfigTunnelingHelper.cs b/Commander/PAM/PamConfigTunnelingHelper.cs new file mode 100644 index 00000000..c6d41b86 --- /dev/null +++ b/Commander/PAM/PamConfigTunnelingHelper.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace Commander.PAM +{ + /// + /// Tunneling / allowedSettings display helpers. DAG read is not available in .NET SDK; + /// returns empty settings when graph data cannot be loaded. + /// + internal static class PamConfigTunnelingHelper + { + public static Dictionary GetAllowedSettingsJson(string configUid) + { + return new Dictionary + { + ["connections"] = null, + ["tunneling"] = null, + ["rotation"] = null, + ["remote_browser_isolation"] = null, + ["connections_recording"] = null, + ["typescript_recording"] = null, + ["ai_threat_detection"] = null, + ["ai_terminate_session_on_detection"] = null, + }; + } + + public static void PrintTunnelingConfig(string configUid) + { + // prints DAG allowedSettings for single-config table list; requires DAG SDK. + } + } +} diff --git a/Commander/PAM/PamRotationCommand.cs b/Commander/PAM/PamRotationCommand.cs index fb4d8e22..c4119d28 100644 --- a/Commander/PAM/PamRotationCommand.cs +++ b/Commander/PAM/PamRotationCommand.cs @@ -741,7 +741,6 @@ private static FieldScript FindScriptValue( continue; } - // Match UID, title, or filename (Python Commander parity). if (string.Equals(fileRecord.Uid, scriptName, StringComparison.OrdinalIgnoreCase) || string.Equals(fileRecord.Title, scriptName, StringComparison.OrdinalIgnoreCase) || string.Equals(fileRecord.Title, scriptNameFolded, StringComparison.OrdinalIgnoreCase) @@ -944,13 +943,13 @@ internal class PamRotationOptions [Option("script", Required = false, HelpText = "Script file path (add) or script UID/name (edit/delete)")] public string Script { get; set; } - [Option("run-command", Required = false, HelpText = "Script command line to run (Python: --script-command)")] + [Option("run-command", Required = false, HelpText = "Script command line to run (--script-command)")] public string RunCommand { get; set; } - [Option("add-credential", Required = false, HelpText = "Record UID with rotation credential (add/edit, -ac in Python Commander)")] + [Option("add-credential", Required = false, HelpText = "Record UID with rotation credential (add/edit, -ac)")] public IEnumerable AddCredential { get; set; } - [Option("remove-credential", Required = false, HelpText = "Remove rotation credential record UID (edit, -rc in Python Commander)")] + [Option("remove-credential", Required = false, HelpText = "Remove rotation credential record UID (edit, -rc)")] public IEnumerable RemoveCredential { get; set; } [Option("pattern", Required = false, HelpText = "Record UID or title filter for script list")] diff --git a/Commander/PAM/PamRotationEditService.cs b/Commander/PAM/PamRotationEditService.cs index bcd3892e..076a6b0f 100644 --- a/Commander/PAM/PamRotationEditService.cs +++ b/Commander/PAM/PamRotationEditService.cs @@ -632,7 +632,6 @@ private static List GetScheduleFromConfig(TypedRecord config) return null; } - // Same as Python: get_typed_field('schedule', 'defaultRotationSchedule') var scheduleField = EnumerateTypedFields(config) .OfType>() .FirstOrDefault(x => @@ -673,14 +672,14 @@ private static List GetScheduleFromConfig(TypedRecord config) dict["month"] = value.Month; } - if (!string.IsNullOrEmpty(value.MonthDay)) + if (value.MonthDay.HasValue) { - dict["monthDay"] = value.MonthDay; + dict["monthDay"] = value.MonthDay.Value; } - if (!string.IsNullOrEmpty(value.IntervalCount)) + if (value.IntervalCount.HasValue) { - dict["intervalCount"] = value.IntervalCount; + dict["intervalCount"] = value.IntervalCount.Value; } if (!string.IsNullOrEmpty(value.Cron)) @@ -872,23 +871,22 @@ private sealed class ResourceConfigureSummary private static void PrintResourceConfigureSummary(IReadOnlyList summaries) { Console.WriteLine(); + foreach (var summary in summaries) { Console.WriteLine( $"Resource \"{summary.RecordTitle}\" ({summary.RecordUid}) configured for PAM rotation."); Console.WriteLine($" PAM Configuration: {summary.ConfigUid}"); + if (!string.IsNullOrEmpty(summary.AdminUserUid)) { Console.WriteLine($" Admin user linked: {summary.AdminUserUid}"); } - if (summary.RotationEnabled == true) - { - Console.WriteLine(" Rotation: Enabled"); - } - else if (summary.RotationEnabled == false) + if (summary.RotationEnabled.HasValue) { - Console.WriteLine(" Rotation: Disabled"); + Console.WriteLine( + $" Rotation: {(summary.RotationEnabled.Value ? "Enabled" : "Disabled")}"); } } } diff --git a/Commander/enterprise/EnterpriseCommands.cs b/Commander/enterprise/EnterpriseCommands.cs index 0ecfaa8b..57f050f1 100644 --- a/Commander/enterprise/EnterpriseCommands.cs +++ b/Commander/enterprise/EnterpriseCommands.cs @@ -286,6 +286,16 @@ internal static void AppendEnterpriseCommands(this IEnterpriseContext context, C }); cli.Aliases["pam-rot"] = "pam-rotation"; + var pamConfig = new PamConfigCommand(context); + cli.Commands.Add("pam-config", + new ParseableCommand + { + Order = 89, + Description = "Manage PAM configurations", + Action = async options => { await pamConfig.ExecuteAsync(options); }, + }); + cli.Aliases["pam-cfg"] = "pam-config"; + cli.Commands.Add("security-audit-report", new ParseableCommand { diff --git a/KeeperSdk/plugins/PAM/ConfigUtils.cs b/KeeperSdk/plugins/PAM/ConfigUtils.cs new file mode 100644 index 00000000..e4c5b54c --- /dev/null +++ b/KeeperSdk/plugins/PAM/ConfigUtils.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Google.Protobuf; +using KeeperSecurity.Authentication; +using KeeperSecurity.Utils; +using KeeperSecurity.Vault; +using PamProto = PAM; +using RouterProto = Router; + +namespace KeeperSecurity.Plugins.PAM +{ + public static class PamConfigTypes + { + public const string EnvironmentLocal = "local"; + public const string EnvironmentNetwork = "network"; + public const string EnvironmentAws = "aws"; + public const string EnvironmentAzure = "azure"; + public const string EnvironmentGcp = "gcp"; + public const string EnvironmentDomain = "domain"; + public const string EnvironmentOci = "oci"; + public const string EnvironmentGithub = "github"; + + private static readonly Dictionary ConfigTypeToRecordType = + new(StringComparer.OrdinalIgnoreCase) + { + [EnvironmentAws] = "pamAwsConfiguration", + [EnvironmentAzure] = "pamAzureConfiguration", + [EnvironmentLocal] = "pamNetworkConfiguration", + [EnvironmentNetwork] = "pamNetworkConfiguration", + [EnvironmentGcp] = "pamGcpConfiguration", + [EnvironmentDomain] = "pamDomainConfiguration", + [EnvironmentOci] = "pamOciConfiguration", + [EnvironmentGithub] = "pamGitHubConfiguration", + }; + + private static readonly Dictionary ComingSoonEnvironments = + new(StringComparer.OrdinalIgnoreCase) + { + [EnvironmentOci] = "OCI", + [EnvironmentGithub] = "GitHub", + }; + + public static bool TryResolveRecordType(string configType, out string recordType) + { + recordType = null; + if (string.IsNullOrWhiteSpace(configType)) + { + return false; + } + + return ConfigTypeToRecordType.TryGetValue(configType.Trim(), out recordType); + } + + public static bool IsComingSoonEnvironment(string configType, out string displayName) + { + displayName = null; + if (string.IsNullOrWhiteSpace(configType)) + { + return false; + } + + return ComingSoonEnvironments.TryGetValue(configType.Trim(), out displayName); + } + + public static string GetSupportedConfigTypes() + { + return string.Join(", ", ConfigTypeToRecordType.Keys); + } + } + + public enum PamTriStateSetting + { + On, + Off, + Default, + } + + public static class ConfigUtils + { + private const string AddConfigurationRecordEndpoint = "pam/add_configuration_record"; + private const string SetConfigurationControllerEndpoint = "pam/set_configuration_controller"; + + public static TypedRecord CreateConfigurationRecord(VaultOnline vault, string recordType, string title) + { + if (vault == null) + { + throw new ArgumentNullException(nameof(vault)); + } + + if (string.IsNullOrWhiteSpace(recordType)) + { + throw new ArgumentException("Record type is required", nameof(recordType)); + } + + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("Title is required", nameof(title)); + } + + var record = new TypedRecord(recordType) { Title = title.Trim(), Version = 6 }; + vault.AdjustTypedRecord(record); + return record; + } + + public static async Task AddConfigurationRecordAsync(VaultOnline vault, TypedRecord record) + { + if (vault == null) + { + throw new ArgumentNullException(nameof(vault)); + } + + if (record == null) + { + throw new ArgumentNullException(nameof(record)); + } + + if (string.IsNullOrEmpty(record.Uid)) + { + record.Uid = CryptoUtils.GenerateUid(); + } + + if (record.RecordKey == null || record.RecordKey.Length == 0) + { + record.RecordKey = CryptoUtils.GenerateEncryptionKey(); + } + + record.Version = 6; + vault.AdjustTypedRecord(record); + var recordData = record.ExtractRecordV3Data(); + var jsonData = JsonUtils.DumpJson(recordData); + jsonData = VaultExtensions.PadRecordData(jsonData); + + var request = new PamProto.ConfigurationAddRequest + { + ConfigurationUid = ByteString.CopyFrom(record.Uid.Base64UrlDecode()), + RecordKey = ByteString.CopyFrom(CryptoUtils.EncryptAesV2(record.RecordKey, vault.Auth.AuthContext.DataKey)), + Data = ByteString.CopyFrom(CryptoUtils.EncryptAesV2(jsonData, record.RecordKey)), + }; + + await vault.Auth.ExecuteAuthRest(AddConfigurationRecordEndpoint, request); + vault.CacheKeeperRecord(record); + } + + public static async Task SetConfigurationGatewayAsync( + IAuthentication auth, + string configurationUid, + string gatewayUid) + { + if (auth == null) + { + throw new ArgumentNullException(nameof(auth)); + } + + if (string.IsNullOrEmpty(configurationUid) || string.IsNullOrEmpty(gatewayUid)) + { + return; + } + + var request = new PamProto.PAMConfigurationController + { + ConfigurationUid = ByteString.CopyFrom(configurationUid.Base64UrlDecode()), + ControllerUid = ByteString.CopyFrom(gatewayUid.Base64UrlDecode()), + }; + + await auth.ExecuteAuthRest(SetConfigurationControllerEndpoint, request); + } + + public static async Task RemovePamConfigurationAsync(VaultOnline vault, string configurationUid) + { + if (vault == null) + { + throw new ArgumentNullException(nameof(vault)); + } + + if (string.IsNullOrEmpty(configurationUid)) + { + throw new ArgumentException("Configuration UID is required", nameof(configurationUid)); + } + + await PamVaultHelpers.DeletePamConfigurationRecordAsync(vault, configurationUid); + } + + public static async Task EnsureConfigurationNetworkGraphAsync( + IAuthentication auth, + string configurationUid) + { + if (auth == null || string.IsNullOrEmpty(configurationUid)) + { + return; + } + + var request = new RouterProto.PAMNetworkConfigurationRequest + { + RecordUid = ByteString.CopyFrom(configurationUid.Base64UrlDecode()), + NetworkSettings = new RouterProto.PAMNetworkSettings + { + AllowedSettings = ByteString.CopyFrom(System.Text.Encoding.UTF8.GetBytes("{}")), + }, + }; + + await RouterUtils.ConfigureNetworkGraphAsync(auth, request); + } + + public static async Task ConfigureTunnelingAsync( + IAuthentication auth, + string configurationUid, + PamTriStateSetting? connections = null, + PamTriStateSetting? tunneling = null, + PamTriStateSetting? rotation = null, + PamTriStateSetting? sessionRecording = null, + PamTriStateSetting? typescriptRecording = null, + PamTriStateSetting? remoteBrowserIsolation = null, + PamTriStateSetting? aiThreatDetection = null, + PamTriStateSetting? aiTerminateSessionOnDetection = null, + IDictionary existingAllowedSettings = null) + { + if (auth == null || string.IsNullOrEmpty(configurationUid)) + { + return; + } + + var allowedSettings = existingAllowedSettings != null + ? new Dictionary(existingAllowedSettings) + : new Dictionary(); + + ApplyTriState(allowedSettings, "connections", connections); + ApplyTriState(allowedSettings, "portForwards", tunneling); + ApplyTriState(allowedSettings, "rotation", rotation); + ApplyTriState(allowedSettings, "sessionRecording", sessionRecording); + ApplyTriState(allowedSettings, "typescriptRecording", typescriptRecording); + ApplyTriState(allowedSettings, "remoteBrowserIsolation", remoteBrowserIsolation); + ApplyTriState(allowedSettings, "aiEnabled", aiThreatDetection); + ApplyTriState(allowedSettings, "aiSessionTerminate", aiTerminateSessionOnDetection); + + if (connections == null && tunneling == null && rotation == null && sessionRecording == null + && typescriptRecording == null && remoteBrowserIsolation == null + && aiThreatDetection == null && aiTerminateSessionOnDetection == null) + { + return; + } + + var request = new RouterProto.PAMNetworkConfigurationRequest + { + RecordUid = ByteString.CopyFrom(configurationUid.Base64UrlDecode()), + NetworkSettings = new RouterProto.PAMNetworkSettings + { + AllowedSettings = ByteString.CopyFrom(JsonUtils.DumpJson(allowedSettings)), + }, + }; + + await RouterUtils.ConfigureNetworkGraphAsync(auth, request); + } + + public static PamTriStateSetting? ParseTriState(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim().ToLowerInvariant() switch + { + "on" => PamTriStateSetting.On, + "off" => PamTriStateSetting.Off, + "default" => PamTriStateSetting.Default, + _ => null, + }; + } + + private static void ApplyTriState( + IDictionary allowedSettings, + string key, + PamTriStateSetting? setting) + { + if (setting == null) + { + return; + } + + var converted = ConvertTriState(setting.Value); + if (converted == null) + { + allowedSettings.Remove(key); + } + else + { + allowedSettings[key] = converted.Value; + } + } + + private static bool? ConvertTriState(PamTriStateSetting setting) + { + return setting switch + { + PamTriStateSetting.On => true, + PamTriStateSetting.Off => false, + PamTriStateSetting.Default => null, + _ => null, + }; + } + } +} diff --git a/KeeperSdk/plugins/PAM/PamConfigurationFacade.cs b/KeeperSdk/plugins/PAM/PamConfigurationFacade.cs new file mode 100644 index 00000000..312c0fac --- /dev/null +++ b/KeeperSdk/plugins/PAM/PamConfigurationFacade.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KeeperSecurity.Vault; + +namespace KeeperSecurity.Plugins.PAM +{ + /// + /// Facade for PAM configuration record pamResources and related fields. + /// + public sealed class PamConfigurationFacade + { + private readonly TypedRecord _record; + private TypedField _pamResources; + + public PamConfigurationFacade(TypedRecord record) + { + _record = record ?? throw new ArgumentNullException(nameof(record)); + LoadPamResources(); + } + + public TypedRecord Record => _record; + + public string ControllerUid + { + get => GetPamResourcesValue()?.ControllerUid ?? ""; + set + { + var resources = EnsurePamResourcesValue(); + resources.ControllerUid = value ?? ""; + } + } + + public string FolderUid + { + get => GetPamResourcesValue()?.FolderUid ?? ""; + set + { + var resources = EnsurePamResourcesValue(); + resources.FolderUid = value ?? ""; + } + } + + public IList ResourceRef + { + get + { + var refs = GetPamResourcesValue()?.ResourceRef; + return refs == null ? new List() : refs.ToList(); + } + } + + public string AdminCredentialRef + { + get => GetPamResourcesValue()?.AdminCredentialRef ?? ""; + set + { + var resources = EnsurePamResourcesValue(); + resources.AdminCredentialRef = value ?? ""; + } + } + + public void RemoveResourceRefs(IEnumerable recordUids) + { + if (recordUids == null) + { + return; + } + + var resources = EnsurePamResourcesValue(); + var remove = new HashSet(recordUids.Where(x => !string.IsNullOrEmpty(x)), StringComparer.Ordinal); + resources.ResourceRef = (resources.ResourceRef ?? Array.Empty()) + .Where(x => !remove.Contains(x)) + .ToArray(); + } + + private void LoadPamResources() + { + TypedField typedField = null; + if (VaultDataExtensions.FindTypedField(_record.Fields, new RecordTypeField("pamResources"), out var field)) + { + typedField = field as TypedField; + } + + if (typedField == null) + { + typedField = VaultDataExtensions.CreateTypedField("pamResources") as TypedField + ?? new TypedField("pamResources"); + _record.Fields.Add(typedField); + } + + _pamResources = typedField; + if (_pamResources.Count == 0) + { + ((ITypedField)_pamResources).AppendValue(); + _pamResources.Values[0] = new FieldPamResources + { + ControllerUid = "", + FolderUid = "", + ResourceRef = Array.Empty(), + }; + } + } + + private FieldPamResources GetPamResourcesValue() + { + return _pamResources?.Count > 0 ? _pamResources.Values[0] : null; + } + + private FieldPamResources EnsurePamResourcesValue() + { + if (_pamResources.Count == 0) + { + ((ITypedField)_pamResources).AppendValue(); + } + + if (_pamResources.Values[0] == null) + { + _pamResources.Values[0] = new FieldPamResources(); + } + + if (_pamResources.Values[0].ResourceRef == null) + { + _pamResources.Values[0].ResourceRef = Array.Empty(); + } + + return _pamResources.Values[0]; + } + } +} diff --git a/KeeperSdk/plugins/PAM/PamRotationGraph.cs b/KeeperSdk/plugins/PAM/PamRotationGraph.cs index 450b723d..716b7425 100644 --- a/KeeperSdk/plugins/PAM/PamRotationGraph.cs +++ b/KeeperSdk/plugins/PAM/PamRotationGraph.cs @@ -682,7 +682,7 @@ private static GraphSyncProto.GraphSyncData BuildGraphSyncData( } /// - /// High-level rotation edit graph operations (Python Commander: config_resource / config_user). + /// High-level rotation edit graph operations. /// public static class PamRotationGraphEdit { diff --git a/KeeperSdk/plugins/PAM/PamVaultHelpers.cs b/KeeperSdk/plugins/PAM/PamVaultHelpers.cs index f1491a51..f46673b9 100644 --- a/KeeperSdk/plugins/PAM/PamVaultHelpers.cs +++ b/KeeperSdk/plugins/PAM/PamVaultHelpers.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using KeeperSecurity.Vault; namespace KeeperSecurity.Plugins.PAM { /// - /// Shared vault lookups for PAM record rotation commands. + /// Shared vault lookups for PAM rotation and configuration commands. /// public static class PamVaultHelpers { @@ -25,14 +26,14 @@ public static Dictionary GetConfigurationRecords(VaultOnlin public static TypedRecord ResolveRecord(VaultOnline vault, string identifier, IEnumerable allowedTypes) { - if (vault == null || string.IsNullOrEmpty(identifier)) + if (vault == null || string.IsNullOrWhiteSpace(identifier)) { return null; } if (TryGetTypedRecord(vault, identifier, out var typedByUid)) { - if (allowedTypes == null || allowedTypes.Contains(typedByUid.TypeName ?? "")) + if (allowedTypes == null || allowedTypes.Contains(typedByUid.TypeName ?? string.Empty)) { return typedByUid; } @@ -45,7 +46,7 @@ public static TypedRecord ResolveRecord(VaultOnline vault, string identifier, IE : new HashSet(allowedTypes, StringComparer.Ordinal); var matches = vault.KeeperRecords .OfType() - .Where(x => allowed == null || allowed.Contains(x.TypeName ?? "")) + .Where(x => allowed == null || allowed.Contains(x.TypeName ?? string.Empty)) .Where(x => string.Equals(x.Title, identifier, StringComparison.OrdinalIgnoreCase)) .ToList(); @@ -62,6 +63,398 @@ public static TypedRecord ResolveRecord(VaultOnline vault, string identifier, IE return null; } + public static SharedFolder FindSharedFolderForRecord(VaultOnline vault, string recordUid, string folderUidHint = null) + { + if (vault == null || string.IsNullOrEmpty(recordUid)) + { + return null; + } + + if (!string.IsNullOrEmpty(folderUidHint) + && vault.TryGetSharedFolder(folderUidHint, out var hinted) + && hinted.RecordPermissions.Any(x => x.RecordUid == recordUid)) + { + return hinted; + } + + return vault.SharedFolders.FirstOrDefault(sf => + sf.RecordPermissions.Any(x => x.RecordUid == recordUid)); + } + + public static string FindRecordFolderUid(VaultOnline vault, string recordUid, string folderUidHint = null) + { + if (vault == null || string.IsNullOrEmpty(recordUid)) + { + return null; + } + + var treeFound = FindInFolder(vault, vault.RootFolder, recordUid); + if (!string.IsNullOrEmpty(treeFound)) + { + return treeFound; + } + + return PickContainingFolder(FindAllContainingFolders(vault, recordUid), folderUidHint); + } + + /// + /// Resolves the source folder for moving a record. Records in the vault cache with no folder + /// links fall back to the vault root. + /// + public static string ResolveRecordSourceFolderUid(VaultOnline vault, string recordUid) + { + var folderUid = FindRecordFolderUid(vault, recordUid); + if (!string.IsNullOrEmpty(folderUid)) + { + return folderUid; + } + + return vault != null && vault.TryGetKeeperRecord(recordUid, out _) + ? vault.RootFolder.FolderUid + : null; + } + + /// + /// Resolves the folder UID for deleting a record. Falls back to root_folder + /// when a record exists in record_cache but has no folder link, and shared-folder permission + /// lookup when pamResources.folderUid is available. + /// + public static string ResolveRecordDeleteFolderUid(VaultOnline vault, string recordUid, string folderUidHint = null) + { + if (vault == null || string.IsNullOrEmpty(recordUid)) + { + return null; + } + + var folderUid = FindRecordFolderUid(vault, recordUid, folderUidHint); + if (!string.IsNullOrEmpty(folderUid)) + { + return folderUid; + } + + var sharedFolder = FindSharedFolderForRecord(vault, recordUid, folderUidHint); + if (sharedFolder != null) + { + if (vault.TryGetFolder(sharedFolder.Uid, out var sfFolder)) + { + return sfFolder.FolderUid; + } + + return sharedFolder.Uid; + } + + return ResolveRecordSourceFolderUid(vault, recordUid); + } + + /// + /// Finds shared folders that contain the record via folder links. + /// + public static IList FindParentTopSharedFolders(VaultOnline vault, string recordUid) + { + var sharedFolders = new List(); + if (vault == null || string.IsNullOrEmpty(recordUid)) + { + return sharedFolders; + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var folder in FindAllContainingFolders(vault, recordUid)) + { + SharedFolder sharedFolder = null; + if (folder.FolderType == FolderType.SharedFolder) + { + vault.TryGetSharedFolder(folder.FolderUid, out sharedFolder); + } + else if (folder.FolderType == FolderType.SharedFolderFolder + && !string.IsNullOrEmpty(folder.SharedFolderUid)) + { + vault.TryGetSharedFolder(folder.SharedFolderUid, out sharedFolder); + } + + if (sharedFolder != null && seen.Add(sharedFolder.Uid)) + { + sharedFolders.Add(sharedFolder); + } + } + + return sharedFolders; + } + + /// + /// Deletes a PAM configuration record. Falls back to root_folder. + /// RecordRemoveCommand(record=uid, force=True). + /// + public static async Task DeletePamConfigurationRecordAsync(VaultOnline vault, string configurationUid) + { + if (vault == null || string.IsNullOrEmpty(configurationUid)) + { + throw new ArgumentException("Configuration UID is required.", nameof(configurationUid)); + } + + if (!EnsureKeeperRecordLoaded(vault, configurationUid)) + { + throw new InvalidOperationException($"Configuration \"{configurationUid}\" not found"); + } + + var paths = BuildRecordRemovePaths(vault, configurationUid); + if (paths.Count == 0) + { + throw new InvalidOperationException($"Could not resolve folder for configuration \"{configurationUid}\""); + } + + await vault.DeleteVaultObjects(paths, forceDelete: true); + } + + /// + /// Deletes a vault record. Falls back to root_folder. + /// + public static async Task DeleteRecordAsync( + VaultOnline vault, + string recordUid, + bool forceDelete = false) + { + if (vault == null || string.IsNullOrEmpty(recordUid)) + { + throw new ArgumentException("Record UID is required.", nameof(recordUid)); + } + + if (!EnsureKeeperRecordLoaded(vault, recordUid)) + { + throw new InvalidOperationException($"Record \"{recordUid}\" not found"); + } + + var paths = BuildRecordRemovePaths(vault, recordUid); + if (paths.Count == 0) + { + throw new InvalidOperationException($"Could not resolve folder for record \"{recordUid}\""); + } + + await vault.DeleteVaultObjects(paths, forceDelete); + } + + public static SharedFolder GetConfigurationSharedFolder(VaultOnline vault, TypedRecord config) + { + if (vault == null || config == null) + { + return null; + } + + return FindParentTopSharedFolders(vault, config.Uid).FirstOrDefault(); + } + + public static bool IsConfigurationInSharedFolder(VaultOnline vault, TypedRecord config) + { + return GetConfigurationSharedFolder(vault, config) != null; + } + + public static void WarnConfigurationNotInSharedFolder(TypedRecord config) + { + if (config == null) + { + return; + } + + Console.WriteLine( + $"Warning: Following configuration is not in the shared folder: UID: {config.Uid}, Title: {config.Title}"); + } + + /// + /// pamResources.folderUid is the top-level shared folder UID. + /// + public static string ResolvePamResourcesFolderUid(VaultOnline vault, string destinationFolderUid) + { + if (vault == null || string.IsNullOrEmpty(destinationFolderUid)) + { + return null; + } + + if (vault.TryGetSharedFolder(destinationFolderUid, out _)) + { + return destinationFolderUid; + } + + if (vault.TryGetFolder(destinationFolderUid, out var folderNode)) + { + return ResolveSharedFolderUid(vault, folderNode) ?? destinationFolderUid; + } + + return destinationFolderUid; + } + + /// + /// Resolves the destination folder UID for pamResources.folderUid. + /// + public static string ResolvePamConfigurationFolderUid( + VaultOnline vault, + string identifier, + Func tryResolveFolderNode = null) + { + if (vault == null || string.IsNullOrWhiteSpace(identifier)) + { + return null; + } + + var trimmed = identifier.Trim(); + if (vault.TryGetSharedFolder(trimmed, out _)) + { + return trimmed; + } + + var nameMatches = vault.SharedFolders + .Where(sf => string.Equals(sf.Name, trimmed, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (nameMatches.Count == 1) + { + return nameMatches[0].Uid; + } + + if (vault.TryGetFolder(trimmed, out var folderByUid) && IsPamSharedFolderDestination(folderByUid)) + { + return folderByUid.FolderUid; + } + + var folderByPath = new BatchVaultOperations(vault); + foreach (var path in GetFolderPathVariants(trimmed)) + { + var node = tryResolveFolderNode?.Invoke(path) ?? folderByPath.GetFolderByPath(path); + if (node != null && IsPamSharedFolderDestination(node)) + { + return node.FolderUid; + } + } + + return null; + } + + public static bool IsPamSharedFolderDestination(FolderNode folderNode) + { + return folderNode != null + && folderNode.FolderType is FolderType.SharedFolder or FolderType.SharedFolderFolder; + } + + public static string ResolveSharedFolderUid(VaultOnline vault, FolderNode folderNode) + { + if (vault == null || folderNode == null) + { + return null; + } + + if (folderNode.FolderType == FolderType.SharedFolder) + { + return folderNode.FolderUid; + } + + if (!string.IsNullOrEmpty(folderNode.SharedFolderUid)) + { + return folderNode.SharedFolderUid; + } + + return null; + } + + private static IEnumerable GetFolderPathVariants(string path) + { + yield return path; + + var backslashPath = path.Replace('/', BatchVaultOperations.PathDelimiter); + if (!string.Equals(backslashPath, path, StringComparison.Ordinal)) + { + yield return backslashPath; + } + + if (!path.StartsWith("/") && !path.StartsWith("\\")) + { + yield return "/" + path; + yield return BatchVaultOperations.PathDelimiter + backslashPath.TrimStart(BatchVaultOperations.PathDelimiter); + } + } + + private static List BuildRecordRemovePaths(VaultOnline vault, string recordUid) + { + var containingFolders = FindAllContainingFolders(vault, recordUid); + if (containingFolders.Count > 0) + { + return containingFolders + .Select(f => new RecordPath { RecordUid = recordUid, FolderUid = f.FolderUid }) + .ToList(); + } + + if (EnsureKeeperRecordLoaded(vault, recordUid)) + { + return new List + { + new RecordPath { RecordUid = recordUid, FolderUid = vault.RootFolder.FolderUid }, + }; + } + + return new List(); + } + + private static bool EnsureKeeperRecordLoaded(VaultOnline vault, string recordUid) + { + return vault.TryGetKeeperRecord(recordUid, out _) + || vault.TryLoadKeeperRecord(recordUid, out _); + } + + private static List FindAllContainingFolders(VaultOnline vault, string recordUid) + { + return Enumerable.Repeat(vault.RootFolder, 1) + .Concat(vault.Folders) + .Where(f => f.Records != null && f.Records.Contains(recordUid)) + .ToList(); + } + + private static string PickContainingFolder(IReadOnlyList containingFolders, string folderUidHint) + { + if (containingFolders == null || containingFolders.Count == 0) + { + return null; + } + + if (containingFolders.Count == 1) + { + return containingFolders[0].FolderUid; + } + + if (!string.IsNullOrEmpty(folderUidHint)) + { + var hinted = containingFolders.FirstOrDefault(f => + string.Equals(f.FolderUid, folderUidHint, StringComparison.Ordinal) + || string.Equals(f.SharedFolderUid, folderUidHint, StringComparison.Ordinal)); + if (hinted != null) + { + return hinted.FolderUid; + } + } + + return (containingFolders.FirstOrDefault(f => f.FolderType == FolderType.UserFolder) + ?? containingFolders[0]).FolderUid; + } + + private static string FindInFolder(VaultOnline vault, FolderNode folder, string recordUid) + { + if (folder.Records.Contains(recordUid)) + { + return folder.FolderUid; + } + + foreach (var subUid in folder.Subfolders) + { + if (!vault.TryGetFolder(subUid, out var subFolder)) + { + continue; + } + + var found = FindInFolder(vault, subFolder, recordUid); + if (!string.IsNullOrEmpty(found)) + { + return found; + } + } + + return null; + } + private static bool TryGetTypedRecord(VaultOnline vault, string recordUid, out TypedRecord record) { record = null; diff --git a/KeeperSdk/plugins/PAM/RotationUtils.cs b/KeeperSdk/plugins/PAM/RotationUtils.cs index 0509215c..56e49ffc 100644 --- a/KeeperSdk/plugins/PAM/RotationUtils.cs +++ b/KeeperSdk/plugins/PAM/RotationUtils.cs @@ -40,6 +40,8 @@ public static (bool IsValid, string Message) ValidateCronExpression(string expre } var parts = expression.Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + // - forRotation: require exactly 6 fields (incl. seconds) + // - otherwise: allow standard 5 or 6 fields if (forRotation) { if (parts.Length != 6) @@ -50,7 +52,7 @@ public static (bool IsValid, string Message) ValidateCronExpression(string expre if (parts[3] != "?" && parts[5] != "?") { - return (false, + Trace.TraceWarning( "CRON: Rotation schedule CRON format - must use ? character in one of these fields: day-of-week, day-of-month"); } @@ -58,8 +60,7 @@ public static (bool IsValid, string Message) ValidateCronExpression(string expre parts[3] = parts[3] == "?" ? "*" : parts[3]; parts[5] = parts[5] == "?" ? "*" : parts[5]; } - - if (parts.Length != 5 && parts.Length != 6) + else if (parts.Length != 5 && parts.Length != 6) { return (false, $"CRON: Expected 5 or 6 fields, got {parts.Length}"); } diff --git a/KeeperSdk/utils/RecordTypesUtils.cs b/KeeperSdk/utils/RecordTypesUtils.cs index 482e39d9..cc247c79 100644 --- a/KeeperSdk/utils/RecordTypesUtils.cs +++ b/KeeperSdk/utils/RecordTypesUtils.cs @@ -19,6 +19,8 @@ public static string ToText(this RecordTypeScope scope) RecordTypeScope.Standard => "standard", RecordTypeScope.Enterprise => "enterprise", RecordTypeScope.User => "user", + RecordTypeScope.Pam => "pam", + RecordTypeScope.PamConfiguration => "pam_configuration", _ => "", }; } diff --git a/KeeperSdk/vault/RecordTypes.cs b/KeeperSdk/vault/RecordTypes.cs index 643aa06f..ee46d58a 100644 --- a/KeeperSdk/vault/RecordTypes.cs +++ b/KeeperSdk/vault/RecordTypes.cs @@ -1,4 +1,4 @@ -using KeeperSecurity.Utils; +using KeeperSecurity.Utils; using System; using System.Collections.Generic; using System.Linq; @@ -265,6 +265,8 @@ public class FieldPamResources : FieldTypeBase public string FolderUid { get; set; } [DataMember(Name = "resourceRef", EmitDefaultValue = true)] public string[] ResourceRef { get; set; } + [DataMember(Name = "adminCredentialRef", EmitDefaultValue = true)] + public string AdminCredentialRef { get; set; } } /// @@ -273,20 +275,27 @@ public class FieldSchedule : FieldTypeBase { [DataMember(Name = "type", EmitDefaultValue = true)] public string Type { get; set; } - [DataMember(Name = "time", EmitDefaultValue = true)] + [DataMember(Name = "time", EmitDefaultValue = false)] public string Time { get; set; } - [DataMember(Name = "tz", EmitDefaultValue = true)] + [DataMember(Name = "tz", EmitDefaultValue = false)] public string TimeZone { get; set; } [DataMember(Name = "weekday", EmitDefaultValue = false)] public string Weekday { get; set; } [DataMember(Name = "month", EmitDefaultValue = false)] public string Month { get; set; } [DataMember(Name = "monthDay", EmitDefaultValue = false)] - public string MonthDay { get; set; } + public int? MonthDay { get; set; } [DataMember(Name = "cron", EmitDefaultValue = false)] public string Cron { get; set; } - [DataMember(Name = "intervalCount", EmitDefaultValue = true)] - public string IntervalCount { get; set; } + [DataMember(Name = "intervalCount", EmitDefaultValue = false)] + public int? IntervalCount { get; set; } + [DataMember(Name = "endDate", EmitDefaultValue = false)] + public string EndDate { get; set; } + [DataMember(Name = "occurrences", EmitDefaultValue = false)] + public int? Occurrences { get; set; } + /// MONTHLY_BY_WEEKDAY: FIRST | SECOND | THIRD | FOURTH | LAST + [DataMember(Name = "occurrence", EmitDefaultValue = false)] + public string Occurrence { get; set; } } /// @@ -1416,6 +1425,7 @@ static RecordTypesConstants() new FieldType("text", typeof(string), "''", "plain text"), new FieldType("url", typeof(string), "''", "url string, can be clicked"), new FieldType("multiline", typeof(string), "''", "multiline text"), + new FieldType("json", typeof(string), "''", "json text; only validated data persisted"), new FieldType("fileRef", typeof(string), "''", "reference to the file field on another record"), new FieldType("email", typeof(string), "''", "valid email address plus tag"), new FieldType("host", typeof(FieldTypeHost), "{'hostName': '', 'port': ''}", "multiple fields to capture host information"), diff --git a/KeeperSdk/vault/SyncDownRest.cs b/KeeperSdk/vault/SyncDownRest.cs index 00953159..dfd642f0 100644 --- a/KeeperSdk/vault/SyncDownRest.cs +++ b/KeeperSdk/vault/SyncDownRest.cs @@ -767,6 +767,7 @@ StorageBreachWatchRecord ToBreachWatchRecord(VaultProto.BreachWatchRecord record Standard = true, Enterprise = true, User = true, + Pam = true, }; var recordTypesRs = await auth.ExecuteAuthRest( @@ -801,6 +802,7 @@ StorageBreachWatchRecord ToBreachWatchRecord(VaultProto.BreachWatchRecord record storage.RecordTypes.PutEntities(recordTypes); vault.RecordTypesLoaded = true; + vault.RefreshRecordTypes(); } Debug.WriteLine("Rebuild Data: Enter"); @@ -830,5 +832,77 @@ private static byte[] DecryptKeeperKey(IAuthContext context, byte[] encryptedKey _ => throw new Exception($"Unsupported key type {keyType}"), }; } + + /// + /// Ensures PAM record type schemas (e.g. pamNetworkConfiguration) are loaded into the vault. + /// + public static async Task EnsurePamRecordTypesAsync(this VaultOnline vault) + { + if (vault == null) + { + throw new ArgumentNullException(nameof(vault)); + } + + if (vault.TryGetRecordTypeByName("pamNetworkConfiguration", out _)) + { + return; + } + + await vault.SyncRecordTypesFromServerAsync(); + } + + /// + /// Downloads record types from Keeper and refreshes the in-memory schema cache. + /// + public static async Task SyncRecordTypesFromServerAsync(this VaultOnline vault) + { + if (vault == null) + { + throw new ArgumentNullException(nameof(vault)); + } + + var recordTypesRq = new RecordProto.RecordTypesRequest + { + Standard = true, + Enterprise = true, + User = true, + Pam = true, + }; + var recordTypesRs = + await vault.Auth.ExecuteAuthRest( + "vault/get_record_types", recordTypesRq); + var recordTypes = recordTypesRs.RecordTypes.Select(x => + { + try + { + var cnt = JsonUtils.ParseJson(Encoding.UTF8.GetBytes(x.Content)); + return new StorageRecordType + { + Name = cnt.Name, + RecordTypeId = x.RecordTypeId, + Content = x.Content, + Scope = (int) x.Scope + }; + } + catch (Exception e) + { + Debug.WriteLine($"Error parsing record type: {e}"); + } + + return null; + }).Where(x => x != null).ToList(); + var existingRecordTypes = new HashSet( + vault.Storage.RecordTypes.GetAll().Select(x => x.Name), + StringComparer.InvariantCultureIgnoreCase); + existingRecordTypes.ExceptWith(recordTypes.Select(x => x.Name)); + if (existingRecordTypes.Count > 0) + { + vault.Storage.RecordTypes.DeleteUids(existingRecordTypes); + } + + vault.Storage.RecordTypes.PutEntities(recordTypes); + vault.RecordTypesLoaded = true; + vault.RefreshRecordTypes(); + } } } diff --git a/KeeperSdk/vault/VaultData.cs b/KeeperSdk/vault/VaultData.cs index 184f8108..f76f2d37 100644 --- a/KeeperSdk/vault/VaultData.cs +++ b/KeeperSdk/vault/VaultData.cs @@ -116,6 +116,19 @@ public bool TryGetKeeperRecord(string recordUid, out KeeperRecord record) return _keeperRecords.TryGetValue(recordUid, out record); } + /// + /// Caches a record created outside the normal vault/records_add flow (e.g. PAM configuration). + /// + public void CacheKeeperRecord(KeeperRecord record) + { + if (record == null || string.IsNullOrEmpty(record.Uid)) + { + return; + } + + _keeperRecords[record.Uid] = record; + } + /// public bool TryLoadKeeperRecord(string recordUid, out KeeperRecord record) { @@ -327,6 +340,11 @@ public bool TryGetAccountUid(string username, out string accountUid) } + internal void RefreshRecordTypes() + { + LoadRecordTypes(); + } + private void LoadRecordTypes() { _keeperRecordTypes.Clear(); @@ -379,13 +397,13 @@ private void LoadRecordTypes() .Where(x => x != null) .ToArray(), }; - if (recordType.Scope == RecordTypeScope.Standard) + if (recordType.Scope == RecordTypeScope.Enterprise) { - _keeperRecordTypes.TryAdd(recordType.Name, recordType); + _customRecordTypes.Add(recordType); } - else if (recordType.Scope == RecordTypeScope.Enterprise) + else { - _customRecordTypes.Add(recordType); + _keeperRecordTypes.TryAdd(recordType.Name, recordType); } } } diff --git a/KeeperSdk/vault/VaultOnline.cs b/KeeperSdk/vault/VaultOnline.cs index bc9bfacd..059cd902 100644 --- a/KeeperSdk/vault/VaultOnline.cs +++ b/KeeperSdk/vault/VaultOnline.cs @@ -317,6 +317,15 @@ public async Task MoveRecords(RecordPath[] records, string dstFolderUid, bool li } } + /// + /// Moves a record without requiring it to appear in the source folder's local record list. + /// Used when moving PAM configuration records from the vault root after pam/add_configuration_record. + /// + public Task MoveRecordToFolder(RecordPath recordPath, string dstFolderUid, bool link = false) + { + return this.MoveToFolder(new[] { recordPath }, dstFolderUid, link); + } + /// public async Task MoveFolder(string srcFolderUid, string dstFolderUid, bool link = false) { diff --git a/KeeperSdk/vault/VaultOnlineFunctions.cs b/KeeperSdk/vault/VaultOnlineFunctions.cs index d22c9c55..50255975 100644 --- a/KeeperSdk/vault/VaultOnlineFunctions.cs +++ b/KeeperSdk/vault/VaultOnlineFunctions.cs @@ -244,6 +244,13 @@ public static async Task AddRecordToFolder(this VaultOnline vault, return vault.TryGetKeeperRecord(record.Uid, out var r) ? r : record; } + private static string EncryptRecordTransitionKey(KeeperRecord record, byte[] encryptionKey) + { + return record.Version >= 3 + ? CryptoUtils.EncryptAesV2(record.RecordKey, encryptionKey).Base64UrlEncode() + : CryptoUtils.EncryptAesV1(record.RecordKey, encryptionKey).Base64UrlEncode(); + } + public static async Task MoveToFolder(this VaultOnline vault, IEnumerable objects, string toFolderUid, bool link = false) { var destinationFolder = vault.GetFolder(toFolderUid); @@ -285,7 +292,7 @@ void TraverseFolderForRecords(FolderNode folder) new TransitionKey { uid = recordUid, - key = CryptoUtils.EncryptAesV1(record.RecordKey, encryptionKey).Base64UrlEncode(), + key = EncryptRecordTransitionKey(record, encryptionKey), }); } } @@ -343,7 +350,7 @@ void TraverseFolderForRecords(FolderNode folder) new TransitionKey { uid = mo.RecordUid, - key = CryptoUtils.EncryptAesV1(record.RecordKey, encryptionKey).Base64UrlEncode(), + key = EncryptRecordTransitionKey(record, encryptionKey), }); } diff --git a/KeeperSdk/vault/VaultStorage.cs b/KeeperSdk/vault/VaultStorage.cs index c5940f96..dc8f5c68 100644 --- a/KeeperSdk/vault/VaultStorage.cs +++ b/KeeperSdk/vault/VaultStorage.cs @@ -538,6 +538,16 @@ public enum RecordTypeScope /// Enterprise-Defined /// Enterprise = 2, + + /// + /// PAM record types + /// + Pam = 3, + + /// + /// PAM configuration record types + /// + PamConfiguration = 4, } ///