From 050d37182fdff4b024469db1bfcac53d87dcf1ec Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Mon, 13 Jul 2026 18:10:21 +0530 Subject: [PATCH 01/10] Added support for PAM Config Command in DotNet SDK and CLI. --- Commander/PAM/PamCommandBase.cs | 135 ++-- Commander/PAM/PamConfigCommand.cs | 719 ++++++++++++++++++ Commander/PAM/PamConfigEditService.cs | 367 +++++++++ Commander/PAM/PamConfigFieldAssigner.cs | 498 ++++++++++++ Commander/PAM/PamConfigFieldDiagnostics.cs | 210 +++++ Commander/PAM/PamConfigScheduleHelper.cs | 325 ++++++++ Commander/PAM/PamConfigTunnelingHelper.cs | 31 + Commander/enterprise/EnterpriseCommands.cs | 10 + KeeperSdk/plugins/PAM/ConfigUtils.cs | 400 ++++++++++ .../plugins/PAM/PamConfigurationFacade.cs | 130 ++++ KeeperSdk/plugins/PAM/PamRecordTypes.cs | 41 + KeeperSdk/plugins/PAM/PamVaultHelpers.cs | 487 ++++++++++++ KeeperSdk/plugins/PAM/RouterUtils.cs | 27 + KeeperSdk/utils/RecordTypesUtils.cs | 2 + KeeperSdk/vault/RecordTypes.cs | 19 +- KeeperSdk/vault/SyncDownRest.cs | 74 ++ KeeperSdk/vault/VaultData.cs | 26 +- KeeperSdk/vault/VaultOnline.cs | 9 + KeeperSdk/vault/VaultOnlineFunctions.cs | 11 +- KeeperSdk/vault/VaultStorage.cs | 10 + 20 files changed, 3466 insertions(+), 65 deletions(-) create mode 100644 Commander/PAM/PamConfigCommand.cs create mode 100644 Commander/PAM/PamConfigEditService.cs create mode 100644 Commander/PAM/PamConfigFieldAssigner.cs create mode 100644 Commander/PAM/PamConfigFieldDiagnostics.cs create mode 100644 Commander/PAM/PamConfigScheduleHelper.cs create mode 100644 Commander/PAM/PamConfigTunnelingHelper.cs create mode 100644 KeeperSdk/plugins/PAM/ConfigUtils.cs create mode 100644 KeeperSdk/plugins/PAM/PamConfigurationFacade.cs create mode 100644 KeeperSdk/plugins/PAM/PamRecordTypes.cs create mode 100644 KeeperSdk/plugins/PAM/PamVaultHelpers.cs diff --git a/Commander/PAM/PamCommandBase.cs b/Commander/PAM/PamCommandBase.cs index 793da263..491db55d 100644 --- a/Commander/PAM/PamCommandBase.cs +++ b/Commander/PAM/PamCommandBase.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Commander; @@ -7,70 +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) { - protected IEnterpriseContext Context { get; } - protected IPamPlugin Plugin { get; private set; } + Context = context ?? throw new ArgumentNullException(nameof(context)); + } - protected PamCommandBase(IEnterpriseContext context) - { - Context = context ?? throw new ArgumentNullException(nameof(context)); - } + 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; + } - 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; - } + if (syncIfNeeded && !Plugin.Controllers.GetAll().Any()) + { + Console.WriteLine("Syncing PAM data..."); + await Plugin.SyncDownAsync(); + } - if (syncIfNeeded && !Plugin.Controllers.GetAll().Any()) - { - Console.WriteLine("Syncing PAM data..."); - await Plugin.SyncDownAsync(); - } + return true; + } - return true; - } + protected PamController ResolveGateway(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + { + return null; + } - protected PamController ResolveGateway(string identifier) - { - if (string.IsNullOrWhiteSpace(identifier)) - { - return null; - } + var controllers = Plugin.Controllers.GetAll(); + var controller = GatewayUtils.FindGateway(controllers, identifier); + if (controller != null) + { + return controller; + } - var controllers = Plugin.Controllers.GetAll(); - var controller = GatewayUtils.FindGateway(controllers, identifier); - if (controller != null) - { - return controller; - } + var trimmed = identifier.Trim(); + var nameMatches = controllers + .Count(c => string.Equals(c.ControllerName, trimmed, StringComparison.OrdinalIgnoreCase)); + if (nameMatches > 1) + { + throw new PamGatewayAmbiguousException(trimmed); + } - var trimmed = identifier.Trim(); - var nameMatches = controllers - .Count(c => string.Equals(c.ControllerName, trimmed, StringComparison.OrdinalIgnoreCase)); - if (nameMatches > 1) - { - throw new PamGatewayAmbiguousException(trimmed); - } + return null; + } - return null; - } + 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; + } + } + + protected VaultContext TryGetVaultContext() + { + if (Context is ConnectedContext connected) + { + return connected._vaultContext; + } - protected async Task SyncPamAsync() - { - if (Plugin == null) - { - Plugin = Context.GetPamPlugin(); - } + return null; + } + + protected async Task SyncPamAsync(bool reload = false) + { + if (Plugin == null) + { + Plugin = Context.GetPamPlugin(); + } - if (Plugin != null) - { - await Plugin.SyncDownAsync(); - } - } + 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..619c1561 --- /dev/null +++ b/Commander/PAM/PamConfigCommand.cs @@ -0,0 +1,719 @@ +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": + await EditConfigurationAsync(options); + break; + case "remove": + case "rm": + case "delete": + await RemoveConfigurationAsync(options); + break; + default: + Console.WriteLine("Unsupported command. Available: list, new, edit, remove"); + break; + } + } + + private async Task ListConfigurationsAsync(PamConfigOptions options) + { + var vault = RequireVault(); + if (!await EnsurePluginAsync(syncIfNeeded: false)) + { + return; + } + + var configId = options.ListConfig ?? options.Uid; + if (!string.IsNullOrWhiteSpace(configId)) + { + await ListSingleConfigurationAsync(vault, options, configId); + return; + } + + var configs = PamVaultHelpers.GetConfigurationRecords(vault).Values + .OrderBy(x => x.Title ?? "", StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (options.isFormatOutputJSON) + { + var rows = new List>(); + foreach (var config in configs) + { + if (!PamVaultHelpers.IsConfigurationInSharedFolder(vault, config)) + { + PamVaultHelpers.WarnConfigurationNotInSharedFolder(config); + continue; + } + + var row = BuildConfigListJson(vault, config, options.Verbose); + if (row != null) + { + rows.Add(row); + } + } + + Console.WriteLine(Json.WriteFormatted(new Dictionary { ["configurations"] = rows })); + return; + } + + var headers = new List + { + "UID", "Config Name", "Config Type", "Shared Folder", "Gateway UID", "Resource Record UIDs" + }; + if (options.Verbose) + { + headers.Add("Fields"); + } + + var tab = new Tabulate(headers.Count); + tab.AddHeader(headers.ToArray()); + foreach (var config in configs) + { + if (!PamVaultHelpers.IsConfigurationInSharedFolder(vault, config)) + { + PamVaultHelpers.WarnConfigurationNotInSharedFolder(config); + continue; + } + + var row = BuildConfigTableRow(vault, config, options.Verbose); + if (row != null) + { + tab.AddRow(row); + } + } + + tab.Dump(); + } + + 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 (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); + PamConfigFieldDiagnostics.LogPlacement(record, vault, "before-save-new"); + + await ConfigUtils.AddConfigurationRecordAsync(vault, record); + await EnsureConfigurationNetworkGraphAsync(record.Uid); + await ConfigureTunnelingIfNeededAsync(record.Uid, options); + + await MoveRecordToSharedFolderAsync(vault, record, moveDestinationUid); + await vault.SyncDown(); + + if (!string.IsNullOrEmpty(facade.ControllerUid)) + { + await ConfigUtils.SetConfigurationControllerAsync(Context.Enterprise.Auth, record.Uid, facade.ControllerUid); + } + + await vault.SyncDown(); + await SyncPamAsync(reload: true); + 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"); + } + + 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 facade = new PamConfigurationFacade(configuration); + var origGatewayUid = facade.ControllerUid; + var origSharedFolderUid = facade.FolderUid; + var origAdminCredRef = facade.AdminCredentialRef; + + var editService = CreateEditService(vault); + editService.ClearWarnings(); + editService.ApplyProperties(configuration, options, isEdit: true); + editService.VerifyRequired(configuration); + PamConfigFieldPlacement.EnsureSchemaFields(vault, configuration); + PamConfigFieldPlacement.RelocateCustomToFields(vault, configuration); + PamConfigFieldDiagnostics.LogPlacement(configuration, vault, "before-save-edit"); + await vault.UpdateRecord(configuration); + + facade = new PamConfigurationFacade(configuration); + if (!string.Equals(facade.ControllerUid, origGatewayUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(facade.ControllerUid)) + { + await ConfigUtils.SetConfigurationControllerAsync( + Context.Enterprise.Auth, configuration.Uid, facade.ControllerUid); + } + + if (!string.Equals(facade.FolderUid, origSharedFolderUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(facade.FolderUid)) + { + await MoveRecordToSharedFolderAsync(vault, configuration, facade.FolderUid); + } + + if (HasTunnelingOptions(options) || !string.Equals(facade.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 async Task EnsureConfigurationNetworkGraphAsync(string configUid) + { + try + { + await ConfigUtils.EnsureConfigurationNetworkGraphAsync(Context.Enterprise.Auth, configUid); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not register PAM configuration network graph: {ex.Message}"); + } + } + + 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(VaultOnline vault, TypedRecord config, bool verbose) + { + var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); + if (sharedFolder == null) + { + return null; + } + + 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(VaultOnline vault, TypedRecord config, bool verbose) + { + var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); + if (sharedFolder == null) + { + return null; + } + + 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 (edit/remove)")] + public string Uid { get; set; } + + [Option("config", Required = false, HelpText = "Specific PAM Configuration UID (list)")] + public string ListConfig { get; set; } + + [Option("environment", Required = false, HelpText = "PAM configuration type: local, aws, azure, gcp, domain, oci")] + 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 expression")] + 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..44adec59 --- /dev/null +++ b/Commander/PAM/PamConfigEditService.cs @@ -0,0 +1,367 @@ +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) + { + if (DomainKwargSupplied(options.DomainId, isEdit)) + { + properties.Add($"text.pamDomainId={options.DomainId ?? ""}"); + } + + if (isEdit) + { + if (options.DomainHostname != null || options.DomainPort != null) + { + var existing = PamConfigFieldAssigner.GetPamHostname(record); + var host = options.DomainHostname != null + ? options.DomainHostname.Trim() + : existing?.HostName ?? ""; + var port = options.DomainPort != null + ? options.DomainPort.Trim() + : existing?.Port ?? ""; + if (!string.IsNullOrEmpty(host) || !string.IsNullOrEmpty(port)) + { + properties.Add($"f.pamHostname=$JSON:{{\"hostName\":\"{EscapeJson(host)}\",\"port\":\"{EscapeJson(port)}\"}}"); + } + else + { + properties.Add("f.pamHostname="); + } + } + } + else + { + var host = options.DomainHostname?.Trim() ?? ""; + var port = options.DomainPort?.Trim() ?? ""; + if (!string.IsNullOrEmpty(host) || !string.IsNullOrEmpty(port)) + { + properties.Add($"f.pamHostname=$JSON:{{\"hostName\":\"{EscapeJson(host)}\",\"port\":\"{EscapeJson(port)}\"}}"); + } + } + + 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); + } + + if (DomainKwargSupplied(options.DomainNetworkCidr, isEdit)) + { + properties.Add($"text.networkCIDR={options.DomainNetworkCidr ?? ""}"); + } + + if (DomainKwargSupplied(options.DomainUserMatch, isEdit)) + { + properties.Add($"text.userMatch={options.DomainUserMatch ?? ""}"); + } + + ApplyDomainAdminCredential(record, options); + } + + 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/PamConfigFieldDiagnostics.cs b/Commander/PAM/PamConfigFieldDiagnostics.cs new file mode 100644 index 00000000..6cd4fced --- /dev/null +++ b/Commander/PAM/PamConfigFieldDiagnostics.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KeeperSecurity.Utils; +using KeeperSecurity.Vault; + +namespace Commander.PAM +{ + /// + /// Reports whether PAM config field values live in fields[] vs custom[] (Web Vault reads fields[]). + /// + internal static class PamConfigFieldDiagnostics + { + public static void LogPlacement(TypedRecord record, VaultData vault, string context) + { + if (record == null) + { + return; + } + + Console.WriteLine($"[pam-config fields] {context}: uid={record.Uid}, type={record.TypeName}, client_modified={record.ClientModified:u}"); + Console.WriteLine($"[pam-config fields] {context}: fields[]={record.Fields.Count}, custom[]={record.Custom.Count}"); + + var schemaKeys = GetSchemaFieldKeys(vault, record.TypeName); + if (schemaKeys != null) + { + Console.WriteLine($"[pam-config fields] {context}: record-type schema has {schemaKeys.Count} field slot(s)"); + } + + foreach (var entry in DescribeAllFields(record, vault)) + { + Console.WriteLine($"[pam-config fields] {context}: {FormatEntry(entry)}"); + } + + var hiddenFromUi = DescribeAllFields(record, vault) + .Where(x => x.HasValue && x.Section == "custom" && x.MatchesSchema) + .ToList(); + if (hiddenFromUi.Count > 0) + { + Console.WriteLine( + $"[pam-config fields] {context}: WEB VAULT LIKELY HIDES {hiddenFromUi.Count} value(s) stored in custom[] " + + "(Commander list still shows them): " + + string.Join(", ", hiddenFromUi.Select(x => x.DisplayName))); + } + + var emptySchemaSlots = DescribeAllFields(record, vault) + .Where(x => x.Section == "fields" && x.MatchesSchema && !x.HasValue) + .Select(x => x.DisplayName) + .ToList(); + var valuedCustomDupes = hiddenFromUi.Select(x => x.DisplayName).ToList(); + if (emptySchemaSlots.Count > 0 && valuedCustomDupes.Count > 0 + && emptySchemaSlots.Intersect(valuedCustomDupes, StringComparer.OrdinalIgnoreCase).Any()) + { + Console.WriteLine( + $"[pam-config fields] {context}: MISPLACED DATA — empty slot in fields[] but value in custom[] " + + "(typical .NET create bug; field values may need to be recreated)"); + } + } + + public static List> BuildPlacementJson(TypedRecord record, VaultData vault) + { + return DescribeAllFields(record, vault) + .Select(x => new Dictionary + { + ["section"] = x.Section, + ["type"] = x.FieldType, + ["label"] = x.FieldLabel ?? "", + ["display_name"] = x.DisplayName, + ["schema_key"] = x.SchemaKey, + ["matches_schema"] = x.MatchesSchema, + ["has_value"] = x.HasValue, + ["web_vault_visible"] = x.WebVaultVisible, + ["value_preview"] = x.ValuePreview ?? "", + }) + .ToList(); + } + + private static HashSet GetSchemaFieldKeys(VaultData vault, string typeName) + { + if (vault == null || !vault.TryGetRecordTypeByName(typeName, out var recordType) || recordType.Fields == null) + { + return null; + } + + return new HashSet( + recordType.Fields.Select(x => x.GetTypedFieldName()), + StringComparer.OrdinalIgnoreCase); + } + + private static IEnumerable DescribeAllFields(TypedRecord record, VaultData vault) + { + var schemaKeys = GetSchemaFieldKeys(vault, record.TypeName); + foreach (var field in record.Fields) + { + yield return DescribeField(field, "fields", schemaKeys); + } + + foreach (var field in record.Custom) + { + yield return DescribeField(field, "custom", schemaKeys); + } + } + + private static FieldPlacementEntry DescribeField( + ITypedField field, + string section, + HashSet schemaKeys) + { + var schemaKey = field.GetTypedFieldName(); + var matchesSchema = schemaKeys?.Contains(schemaKey) == true; + var hasValue = FieldHasValue(field); + var preview = BuildValuePreview(field); + + return new FieldPlacementEntry + { + Section = section, + FieldType = field.FieldName ?? "", + FieldLabel = field.FieldLabel ?? "", + DisplayName = PamConfigScheduleHelper.GetPamFieldDisplayName(field), + SchemaKey = schemaKey, + MatchesSchema = matchesSchema, + HasValue = hasValue, + WebVaultVisible = section == "fields" && hasValue, + ValuePreview = preview, + }; + } + + 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 && string.IsNullOrWhiteSpace(s)) + { + continue; + } + + if (value is FieldSchedule schedule) + { + if (!string.IsNullOrWhiteSpace(schedule.Type) + && !string.Equals(schedule.Type, "ON_DEMAND", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(schedule.Type, "ON_DEMAND", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + continue; + } + + return true; + } + + return false; + } + + private static string BuildValuePreview(ITypedField field) + { + if (string.Equals(field.FieldName, "schedule", StringComparison.Ordinal)) + { + var values = PamConfigScheduleHelper.GetDisplayValues(field).ToList(); + return values.Count > 0 ? string.Join(", ", values) : ""; + } + + if (string.Equals(field.FieldName, "secret", StringComparison.Ordinal) + || string.Equals(field.FieldName, "password", StringComparison.Ordinal)) + { + return field.Count > 0 ? "***" : ""; + } + + var parts = field.GetTypedFieldInformation().ToList(); + return parts.Count > 0 ? string.Join(", ", parts) : ""; + } + + private static string FormatEntry(FieldPlacementEntry entry) + { + var storage = entry.Section == "fields" ? "fields[]" : "custom[]"; + var ui = entry.WebVaultVisible ? "web_ui=visible" : entry.HasValue ? "web_ui=hidden" : "web_ui=empty"; + var schema = entry.MatchesSchema ? "schema=match" : "schema=extra"; + var preview = string.IsNullOrEmpty(entry.ValuePreview) ? "(empty)" : entry.ValuePreview; + return $"{storage} {entry.DisplayName} [{schema}, {ui}] value={preview}"; + } + + private sealed class FieldPlacementEntry + { + public string Section { get; set; } + public string FieldType { get; set; } + public string FieldLabel { get; set; } + public string DisplayName { get; set; } + public string SchemaKey { get; set; } + public bool MatchesSchema { get; set; } + public bool HasValue { get; set; } + public bool WebVaultVisible { get; set; } + public string ValuePreview { get; set; } + } + } +} diff --git a/Commander/PAM/PamConfigScheduleHelper.cs b/Commander/PAM/PamConfigScheduleHelper.cs new file mode 100644 index 00000000..0c8ecdef --- /dev/null +++ b/Commander/PAM/PamConfigScheduleHelper.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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) + { + var cron = options.DefaultSchedule?.Trim(); + + if (isEdit && string.IsNullOrWhiteSpace(cron)) + { + SetOnDemandSchedule(record); + return; + } + + if (!string.IsNullOrWhiteSpace(cron)) + { + cron = NormalizeRotationCronForStorage(cron); + var (isValid, message) = PamCronUtils.ValidateCronExpression(cron, forRotation: true); + if (!isValid) + { + throw new InvalidOperationException($"Invalid CRON \"{cron}\" Error: {message}"); + } + + SetCronSchedule(record, cron); + } + else + { + SetOnDemandSchedule(record); + } + } + + 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; + } + + var comps = cron.Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (comps.Length >= 6) + { + return string.Join(" ", comps.Skip(1).Take(5)); + } + + return cron.Trim(); + } + + private static string NormalizeRotationCronForStorage(string cron) + { + var trimmed = cron.Trim(); + var comps = trimmed.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (comps.Length == 5) + { + return $"0 {trimmed}"; + } + + return trimmed; + } + + 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/enterprise/EnterpriseCommands.cs b/Commander/enterprise/EnterpriseCommands.cs index 4f2e4802..168f4a00 100644 --- a/Commander/enterprise/EnterpriseCommands.cs +++ b/Commander/enterprise/EnterpriseCommands.cs @@ -275,6 +275,16 @@ internal static void AppendEnterpriseCommands(this IEnterpriseContext context, C }); cli.Aliases["pam-gw"] = "pam-gateway"; + var pamConfig = new PamConfigCommand(context); + cli.Commands.Add("pam-config", + new ParseableCommand + { + Order = 88, + 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..136189b3 --- /dev/null +++ b/KeeperSdk/plugins/PAM/ConfigUtils.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.RegularExpressions; +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"; + + private static readonly Dictionary ConfigTypeToRecordType = + new(StringComparer.OrdinalIgnoreCase) + { + [EnvironmentAws] = "pamAwsConfiguration", + [EnvironmentAzure] = "pamAzureConfiguration", + [EnvironmentLocal] = "pamNetworkConfiguration", + [EnvironmentNetwork] = "pamNetworkConfiguration", + [EnvironmentGcp] = "pamGcpConfiguration", + [EnvironmentDomain] = "pamDomainConfiguration", + [EnvironmentOci] = "pamOciConfiguration", + }; + + 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 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 SetConfigurationControllerAsync( + IAuthentication auth, + string configurationUid, + string controllerUid) + { + if (auth == null) + { + throw new ArgumentNullException(nameof(auth)); + } + + if (string.IsNullOrEmpty(configurationUid) || string.IsNullOrEmpty(controllerUid)) + { + return; + } + + var request = new PamProto.PAMConfigurationController + { + ConfigurationUid = ByteString.CopyFrom(configurationUid.Base64UrlDecode()), + ControllerUid = ByteString.CopyFrom(controllerUid.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, + }; + } + } + + public static class PamCronUtils + { + private static readonly Regex CronFieldPattern = new( + @"^(\*|\d+L?|L[W]?|\d+-\d+|\*/\d+|\d+(,\d+)*|\d+-\d+/\d+)$", + RegexOptions.Compiled); + + public static (bool IsValid, string Message) ValidateCronExpression(string expression, bool forRotation = false) + { + if (string.IsNullOrWhiteSpace(expression)) + { + return (false, "CRON: Expression is required"); + } + + var parts = expression.Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (forRotation) + { + if (parts.Length != 6) + { + return (false, + $"CRON: Rotation schedules require all 6 parts incl. seconds - ex. Daily at 04:00:00 cron: 0 0 4 * * ? got {parts.Length} parts"); + } + + if (parts[3] != "?" && parts[5] != "?") + { + Trace.TraceWarning( + "CRON: Rotation schedule CRON format - must use ? character in one of these fields: day-of-week, day-of-month"); + } + + parts = (string[])parts.Clone(); + parts[3] = parts[3] == "?" ? "*" : parts[3]; + parts[5] = parts[5] == "?" ? "*" : parts[5]; + } + + if (parts.Length != 5 && parts.Length != 6) + { + return (false, $"CRON: Expected 5 or 6 fields, got {parts.Length}"); + } + + string minute; + string hour; + string dom; + string month; + string dow; + if (parts.Length == 6) + { + if (!ValidateCronField(parts[0], 0, 59)) + { + return (false, "CRON: Invalid seconds field"); + } + + minute = parts[1]; + hour = parts[2]; + dom = parts[3]; + month = parts[4]; + dow = parts[5]; + } + else + { + minute = parts[0]; + hour = parts[1]; + dom = parts[2]; + month = parts[3]; + dow = parts[4]; + } + + (string field, int min, int max, string name)[] validators = + { + (minute, 0, 59, "minute"), + (hour, 0, 23, "hour"), + (dom, 1, 31, "day of month"), + (month, 1, 12, "month"), + (dow, 0, 7, "day of week"), + }; + + foreach (var (field, min, max, name) in validators) + { + if (!ValidateCronField(field, min, max)) + { + return (false, $"CRON: Invalid {name} field"); + } + } + + return (true, "Valid cron expression"); + } + + private static bool ValidateCronField(string field, int minVal, int maxVal) + { + if (!CronFieldPattern.IsMatch(field)) + { + return false; + } + + foreach (var part in Regex.Split(field, @"[,\-/]")) + { + if (part == "*" || part.Length == 0 || part == "L" || part == "LW") + { + continue; + } + + var stripped = part.TrimEnd('L', 'W'); + if (stripped.Length == 0 || !int.TryParse(stripped, out var number)) + { + return false; + } + + if (number < minVal || number > maxVal) + { + return false; + } + } + + return true; + } + } +} 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/PamRecordTypes.cs b/KeeperSdk/plugins/PAM/PamRecordTypes.cs new file mode 100644 index 00000000..03e3f79d --- /dev/null +++ b/KeeperSdk/plugins/PAM/PamRecordTypes.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace KeeperSecurity.Plugins.PAM +{ + /// + /// PAM vault record type names used by rotation and configuration commands. + /// + public static class PamRecordTypes + { + public static readonly HashSet Rotation = Create( + "pamUser", + "pamDirectory", + "pamDatabase", + "pamMachine", + "pamRemoteBrowser"); + + public static readonly HashSet Resource = Create( + "pamMachine", + "pamDatabase", + "pamDirectory", + "pamRemoteBrowser"); + + public static readonly HashSet Script = Create( + "pamUser", + "pamDirectory"); + + public static readonly HashSet Configuration = Create( + "pamAwsConfiguration", + "pamAzureConfiguration", + "pamGcpConfiguration", + "pamDomainConfiguration", + "pamNetworkConfiguration", + "pamOciConfiguration"); + + private static HashSet Create(params string[] types) + { + return new HashSet(types, StringComparer.Ordinal); + } + } +} diff --git a/KeeperSdk/plugins/PAM/PamVaultHelpers.cs b/KeeperSdk/plugins/PAM/PamVaultHelpers.cs new file mode 100644 index 00000000..95b9e8e6 --- /dev/null +++ b/KeeperSdk/plugins/PAM/PamVaultHelpers.cs @@ -0,0 +1,487 @@ +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 rotation and configuration commands. + /// + public static class PamVaultHelpers + { + public static Dictionary GetConfigurationRecords(VaultOnline vault) + { + if (vault == null) + { + return new Dictionary(); + } + + return vault.KeeperRecords + .OfType() + .Where(x => PamRecordTypes.Configuration.Contains(x.TypeName ?? "")) + .ToDictionary(x => x.Uid, x => x); + } + + public static TypedRecord ResolveRecord(VaultOnline vault, string identifier, IEnumerable allowedTypes) + { + if (vault == null || string.IsNullOrEmpty(identifier)) + { + return null; + } + + if (TryGetTypedRecord(vault, identifier, out var typedByUid)) + { + if (allowedTypes == null || allowedTypes.Contains(typedByUid.TypeName ?? "")) + { + return typedByUid; + } + + return null; + } + + var allowed = allowedTypes == null + ? null + : new HashSet(allowedTypes, StringComparer.Ordinal); + var matches = vault.KeeperRecords + .OfType() + .Where(x => allowed == null || allowed.Contains(x.TypeName ?? "")) + .Where(x => string.Equals(x.Title, identifier, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matches.Count == 1) + { + return matches[0]; + } + + if (matches.Count > 1) + { + throw new InvalidOperationException($"Record name '{identifier}' is not unique. Use record UID."); + } + + 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; + if (!vault.TryGetKeeperRecord(recordUid, out var keeper)) + { + return false; + } + + if (keeper is TypedRecord typed) + { + record = typed; + return true; + } + + if (vault.TryLoadKeeperRecord(recordUid, out keeper) && keeper is TypedRecord loaded) + { + record = loaded; + return true; + } + + return false; + } + + public static bool TryGetUserRecord(VaultOnline vault, string recordUid, out TypedRecord record) + { + record = ResolveRecord(vault, recordUid, new[] { "pamUser" }); + return record != null; + } + } +} diff --git a/KeeperSdk/plugins/PAM/RouterUtils.cs b/KeeperSdk/plugins/PAM/RouterUtils.cs index 01bfce00..f04864f2 100644 --- a/KeeperSdk/plugins/PAM/RouterUtils.cs +++ b/KeeperSdk/plugins/PAM/RouterUtils.cs @@ -2,9 +2,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Google.Protobuf; using KeeperSecurity.Authentication; using KeeperSecurity.Utils; using PamProto = PAM; +using RouterProto = Router; namespace KeeperSecurity.Plugins.PAM { @@ -34,5 +36,30 @@ public static IList GetConnectedGatewayUids(PamProto.PAMOnlineController .Select(x => x.ControllerUid.ToByteArray()) .ToList(); } + + public static async Task ConfigureNetworkGraphAsync( + IAuthentication auth, + RouterProto.PAMNetworkConfigurationRequest request) + { + if (auth == null) + { + throw new ArgumentNullException(nameof(auth)); + } + + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (auth.Endpoint is not KeeperEndpoint keeperEndpoint) + { + throw new InvalidOperationException("Endpoint must be KeeperEndpoint to use ConfigureNetworkGraphAsync"); + } + + await keeperEndpoint.ExecuteRouterRest( + "configure_network_graph", + auth.AuthContext.SessionToken, + request.ToByteArray()); + } } } 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..bcf97a20 100644 --- a/KeeperSdk/vault/RecordTypes.cs +++ b/KeeperSdk/vault/RecordTypes.cs @@ -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; } } /// 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 da8688f5..3344dec8 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 bd3db608..743915e8 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, } /// From d4d612bf7f30e8d2c259868bf598e53c554fffca Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Tue, 14 Jul 2026 12:24:41 +0530 Subject: [PATCH 02/10] Fixed display field issues. --- Commander/PAM/PamConfigCommand.cs | 4 +- Commander/PAM/PamConfigFieldDiagnostics.cs | 210 --------------------- Commander/PAM/PamConfigScheduleHelper.cs | 16 +- KeeperSdk/plugins/PAM/ConfigUtils.cs | 7 - 4 files changed, 2 insertions(+), 235 deletions(-) delete mode 100644 Commander/PAM/PamConfigFieldDiagnostics.cs diff --git a/Commander/PAM/PamConfigCommand.cs b/Commander/PAM/PamConfigCommand.cs index 619c1561..dc4fcecb 100644 --- a/Commander/PAM/PamConfigCommand.cs +++ b/Commander/PAM/PamConfigCommand.cs @@ -221,7 +221,6 @@ private async Task NewConfigurationAsync(PamConfigOptions options) PamConfigFieldPlacement.EnsureSchemaFields(vault, record); PamConfigFieldPlacement.RelocateCustomToFields(vault, record); - PamConfigFieldDiagnostics.LogPlacement(record, vault, "before-save-new"); await ConfigUtils.AddConfigurationRecordAsync(vault, record); await EnsureConfigurationNetworkGraphAsync(record.Uid); @@ -290,7 +289,6 @@ private async Task EditConfigurationAsync(PamConfigOptions options) editService.VerifyRequired(configuration); PamConfigFieldPlacement.EnsureSchemaFields(vault, configuration); PamConfigFieldPlacement.RelocateCustomToFields(vault, configuration); - PamConfigFieldDiagnostics.LogPlacement(configuration, vault, "before-save-edit"); await vault.UpdateRecord(configuration); facade = new PamConfigurationFacade(configuration); @@ -579,7 +577,7 @@ internal class PamConfigOptions : EnterpriseGenericOptions [Option("shared-folder", Required = false, HelpText = "Shared folder path or UID")] public string SharedFolder { get; set; } - [Option("schedule", Required = false, HelpText = "Default schedule CRON expression")] + [Option("schedule", Required = false, HelpText = "Default schedule CRON expression (e.g. 0 0 2 * * ?)")] public string DefaultSchedule { get; set; } [Option("port-mapping", Required = false, HelpText = "Port mapping entry")] diff --git a/Commander/PAM/PamConfigFieldDiagnostics.cs b/Commander/PAM/PamConfigFieldDiagnostics.cs deleted file mode 100644 index 6cd4fced..00000000 --- a/Commander/PAM/PamConfigFieldDiagnostics.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KeeperSecurity.Utils; -using KeeperSecurity.Vault; - -namespace Commander.PAM -{ - /// - /// Reports whether PAM config field values live in fields[] vs custom[] (Web Vault reads fields[]). - /// - internal static class PamConfigFieldDiagnostics - { - public static void LogPlacement(TypedRecord record, VaultData vault, string context) - { - if (record == null) - { - return; - } - - Console.WriteLine($"[pam-config fields] {context}: uid={record.Uid}, type={record.TypeName}, client_modified={record.ClientModified:u}"); - Console.WriteLine($"[pam-config fields] {context}: fields[]={record.Fields.Count}, custom[]={record.Custom.Count}"); - - var schemaKeys = GetSchemaFieldKeys(vault, record.TypeName); - if (schemaKeys != null) - { - Console.WriteLine($"[pam-config fields] {context}: record-type schema has {schemaKeys.Count} field slot(s)"); - } - - foreach (var entry in DescribeAllFields(record, vault)) - { - Console.WriteLine($"[pam-config fields] {context}: {FormatEntry(entry)}"); - } - - var hiddenFromUi = DescribeAllFields(record, vault) - .Where(x => x.HasValue && x.Section == "custom" && x.MatchesSchema) - .ToList(); - if (hiddenFromUi.Count > 0) - { - Console.WriteLine( - $"[pam-config fields] {context}: WEB VAULT LIKELY HIDES {hiddenFromUi.Count} value(s) stored in custom[] " + - "(Commander list still shows them): " + - string.Join(", ", hiddenFromUi.Select(x => x.DisplayName))); - } - - var emptySchemaSlots = DescribeAllFields(record, vault) - .Where(x => x.Section == "fields" && x.MatchesSchema && !x.HasValue) - .Select(x => x.DisplayName) - .ToList(); - var valuedCustomDupes = hiddenFromUi.Select(x => x.DisplayName).ToList(); - if (emptySchemaSlots.Count > 0 && valuedCustomDupes.Count > 0 - && emptySchemaSlots.Intersect(valuedCustomDupes, StringComparer.OrdinalIgnoreCase).Any()) - { - Console.WriteLine( - $"[pam-config fields] {context}: MISPLACED DATA — empty slot in fields[] but value in custom[] " + - "(typical .NET create bug; field values may need to be recreated)"); - } - } - - public static List> BuildPlacementJson(TypedRecord record, VaultData vault) - { - return DescribeAllFields(record, vault) - .Select(x => new Dictionary - { - ["section"] = x.Section, - ["type"] = x.FieldType, - ["label"] = x.FieldLabel ?? "", - ["display_name"] = x.DisplayName, - ["schema_key"] = x.SchemaKey, - ["matches_schema"] = x.MatchesSchema, - ["has_value"] = x.HasValue, - ["web_vault_visible"] = x.WebVaultVisible, - ["value_preview"] = x.ValuePreview ?? "", - }) - .ToList(); - } - - private static HashSet GetSchemaFieldKeys(VaultData vault, string typeName) - { - if (vault == null || !vault.TryGetRecordTypeByName(typeName, out var recordType) || recordType.Fields == null) - { - return null; - } - - return new HashSet( - recordType.Fields.Select(x => x.GetTypedFieldName()), - StringComparer.OrdinalIgnoreCase); - } - - private static IEnumerable DescribeAllFields(TypedRecord record, VaultData vault) - { - var schemaKeys = GetSchemaFieldKeys(vault, record.TypeName); - foreach (var field in record.Fields) - { - yield return DescribeField(field, "fields", schemaKeys); - } - - foreach (var field in record.Custom) - { - yield return DescribeField(field, "custom", schemaKeys); - } - } - - private static FieldPlacementEntry DescribeField( - ITypedField field, - string section, - HashSet schemaKeys) - { - var schemaKey = field.GetTypedFieldName(); - var matchesSchema = schemaKeys?.Contains(schemaKey) == true; - var hasValue = FieldHasValue(field); - var preview = BuildValuePreview(field); - - return new FieldPlacementEntry - { - Section = section, - FieldType = field.FieldName ?? "", - FieldLabel = field.FieldLabel ?? "", - DisplayName = PamConfigScheduleHelper.GetPamFieldDisplayName(field), - SchemaKey = schemaKey, - MatchesSchema = matchesSchema, - HasValue = hasValue, - WebVaultVisible = section == "fields" && hasValue, - ValuePreview = preview, - }; - } - - 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 && string.IsNullOrWhiteSpace(s)) - { - continue; - } - - if (value is FieldSchedule schedule) - { - if (!string.IsNullOrWhiteSpace(schedule.Type) - && !string.Equals(schedule.Type, "ON_DEMAND", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (string.Equals(schedule.Type, "ON_DEMAND", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - continue; - } - - return true; - } - - return false; - } - - private static string BuildValuePreview(ITypedField field) - { - if (string.Equals(field.FieldName, "schedule", StringComparison.Ordinal)) - { - var values = PamConfigScheduleHelper.GetDisplayValues(field).ToList(); - return values.Count > 0 ? string.Join(", ", values) : ""; - } - - if (string.Equals(field.FieldName, "secret", StringComparison.Ordinal) - || string.Equals(field.FieldName, "password", StringComparison.Ordinal)) - { - return field.Count > 0 ? "***" : ""; - } - - var parts = field.GetTypedFieldInformation().ToList(); - return parts.Count > 0 ? string.Join(", ", parts) : ""; - } - - private static string FormatEntry(FieldPlacementEntry entry) - { - var storage = entry.Section == "fields" ? "fields[]" : "custom[]"; - var ui = entry.WebVaultVisible ? "web_ui=visible" : entry.HasValue ? "web_ui=hidden" : "web_ui=empty"; - var schema = entry.MatchesSchema ? "schema=match" : "schema=extra"; - var preview = string.IsNullOrEmpty(entry.ValuePreview) ? "(empty)" : entry.ValuePreview; - return $"{storage} {entry.DisplayName} [{schema}, {ui}] value={preview}"; - } - - private sealed class FieldPlacementEntry - { - public string Section { get; set; } - public string FieldType { get; set; } - public string FieldLabel { get; set; } - public string DisplayName { get; set; } - public string SchemaKey { get; set; } - public bool MatchesSchema { get; set; } - public bool HasValue { get; set; } - public bool WebVaultVisible { get; set; } - public string ValuePreview { get; set; } - } - } -} diff --git a/Commander/PAM/PamConfigScheduleHelper.cs b/Commander/PAM/PamConfigScheduleHelper.cs index 0c8ecdef..41fd3383 100644 --- a/Commander/PAM/PamConfigScheduleHelper.cs +++ b/Commander/PAM/PamConfigScheduleHelper.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Text; using KeeperSecurity.Plugins.PAM; using KeeperSecurity.Utils; @@ -187,25 +186,12 @@ private static string ExportCronScheduleField(string cron) return null; } - var comps = cron.Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); - if (comps.Length >= 6) - { - return string.Join(" ", comps.Skip(1).Take(5)); - } - return cron.Trim(); } private static string NormalizeRotationCronForStorage(string cron) { - var trimmed = cron.Trim(); - var comps = trimmed.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); - if (comps.Length == 5) - { - return $"0 {trimmed}"; - } - - return trimmed; + return cron?.Trim(); } private static FieldSchedule NormalizeSchedule(FieldSchedule source) diff --git a/KeeperSdk/plugins/PAM/ConfigUtils.cs b/KeeperSdk/plugins/PAM/ConfigUtils.cs index 136189b3..73fc29f4 100644 --- a/KeeperSdk/plugins/PAM/ConfigUtils.cs +++ b/KeeperSdk/plugins/PAM/ConfigUtils.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Text.RegularExpressions; using System.Threading.Tasks; using Google.Protobuf; @@ -305,12 +304,6 @@ public static (bool IsValid, string Message) ValidateCronExpression(string expre $"CRON: Rotation schedules require all 6 parts incl. seconds - ex. Daily at 04:00:00 cron: 0 0 4 * * ? got {parts.Length} parts"); } - if (parts[3] != "?" && parts[5] != "?") - { - Trace.TraceWarning( - "CRON: Rotation schedule CRON format - must use ? character in one of these fields: day-of-week, day-of-month"); - } - parts = (string[])parts.Clone(); parts[3] = parts[3] == "?" ? "*" : parts[3]; parts[5] = parts[5] == "?" ? "*" : parts[5]; From f3bc43c89354506816296da39a946e3079ce72f6 Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Wed, 15 Jul 2026 17:51:36 +0530 Subject: [PATCH 03/10] Reviewed changes --- Commander/PAM/PamConfigCommand.cs | 157 +++++++++++++---------- Commander/PAM/PamConfigEditService.cs | 92 ++++++++----- KeeperSdk/plugins/PAM/PamVaultHelpers.cs | 6 +- KeeperSdk/vault/RecordTypes.cs | 3 +- 4 files changed, 154 insertions(+), 104 deletions(-) diff --git a/Commander/PAM/PamConfigCommand.cs b/Commander/PAM/PamConfigCommand.cs index dc4fcecb..ce4adc53 100644 --- a/Commander/PAM/PamConfigCommand.cs +++ b/Commander/PAM/PamConfigCommand.cs @@ -38,16 +38,25 @@ public async Task ExecuteAsync(PamConfigOptions 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: - Console.WriteLine("Unsupported command. Available: list, new, edit, remove"); - break; + throw new InvalidOperationException("Unsupported command. Available: list, new, edit, remove"); } } @@ -67,36 +76,48 @@ private async Task ListConfigurationsAsync(PamConfigOptions options) } var configs = PamVaultHelpers.GetConfigurationRecords(vault).Values - .OrderBy(x => x.Title ?? "", StringComparer.OrdinalIgnoreCase) - .ToList(); + .OrderBy(x => x.Title ?? string.Empty, StringComparer.OrdinalIgnoreCase); if (options.isFormatOutputJSON) { - var rows = new List>(); - foreach (var config in configs) - { - if (!PamVaultHelpers.IsConfigurationInSharedFolder(vault, config)) - { - PamVaultHelpers.WarnConfigurationNotInSharedFolder(config); - continue; - } + ListConfigurationsAsJson(vault, configs, options.Verbose); + } + else + { + ListConfigurationsAsTable(vault, configs, options.Verbose); + } + } - var row = BuildConfigListJson(vault, config, options.Verbose); - if (row != null) - { - rows.Add(row); - } + 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; } - Console.WriteLine(Json.WriteFormatted(new Dictionary { ["configurations"] = rows })); - return; + 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 (options.Verbose) + if (verbose) { headers.Add("Fields"); } @@ -105,22 +126,29 @@ private async Task ListConfigurationsAsync(PamConfigOptions options) tab.AddHeader(headers.ToArray()); foreach (var config in configs) { - if (!PamVaultHelpers.IsConfigurationInSharedFolder(vault, config)) + var sharedFolder = TryGetListedConfigurationFolder(vault, config); + if (sharedFolder == null) { - PamVaultHelpers.WarnConfigurationNotInSharedFolder(config); continue; } - var row = BuildConfigTableRow(vault, config, options.Verbose); - if (row != null) - { - tab.AddRow(row); - } + 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); @@ -177,6 +205,12 @@ private async Task NewConfigurationAsync(PamConfigOptions options) $"--environment parameter is required. Supported options: {PamConfigTypes.GetSupportedConfigTypes()}"); } + if (string.Equals(options.Environment?.Trim(), PamConfigTypes.EnvironmentOci, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Environment OCI 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"); @@ -223,11 +257,11 @@ private async Task NewConfigurationAsync(PamConfigOptions options) PamConfigFieldPlacement.RelocateCustomToFields(vault, record); await ConfigUtils.AddConfigurationRecordAsync(vault, record); - await EnsureConfigurationNetworkGraphAsync(record.Uid); + await ConfigUtils.EnsureConfigurationNetworkGraphAsync(Context.Enterprise.Auth, record.Uid); await ConfigureTunnelingIfNeededAsync(record.Uid, options); - await MoveRecordToSharedFolderAsync(vault, record, moveDestinationUid); await vault.SyncDown(); + await MoveRecordToSharedFolderAsync(vault, record, moveDestinationUid); if (!string.IsNullOrEmpty(facade.ControllerUid)) { @@ -235,7 +269,6 @@ private async Task NewConfigurationAsync(PamConfigOptions options) } await vault.SyncDown(); - await SyncPamAsync(reload: true); editService.LogWarnings(); Console.WriteLine(record.Uid); } @@ -259,6 +292,12 @@ private async Task EditConfigurationAsync(PamConfigOptions options) throw new InvalidOperationException($"PAM configuration \"{options.Uid}\" not found"); } + if (string.Equals(options.Environment?.Trim(), PamConfigTypes.EnvironmentOci, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Environment OCI is not supported yet. It will be supported in a future release."); + return; + } + await vault.EnsurePamRecordTypesAsync(); if (!string.IsNullOrWhiteSpace(options.Environment) @@ -278,10 +317,10 @@ private async Task EditConfigurationAsync(PamConfigOptions options) configuration.Title = options.Title.Trim(); } - var facade = new PamConfigurationFacade(configuration); - var origGatewayUid = facade.ControllerUid; - var origSharedFolderUid = facade.FolderUid; - var origAdminCredRef = facade.AdminCredentialRef; + var beforeEdit = new PamConfigurationFacade(configuration); + var origGatewayUid = beforeEdit.ControllerUid; + var origSharedFolderUid = beforeEdit.FolderUid; + var origAdminCredRef = beforeEdit.AdminCredentialRef; var editService = CreateEditService(vault); editService.ClearWarnings(); @@ -291,21 +330,21 @@ private async Task EditConfigurationAsync(PamConfigOptions options) PamConfigFieldPlacement.RelocateCustomToFields(vault, configuration); await vault.UpdateRecord(configuration); - facade = new PamConfigurationFacade(configuration); - if (!string.Equals(facade.ControllerUid, origGatewayUid, StringComparison.Ordinal) - && !string.IsNullOrEmpty(facade.ControllerUid)) + var afterEdit = new PamConfigurationFacade(configuration); + if (!string.Equals(afterEdit.ControllerUid, origGatewayUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(afterEdit.ControllerUid)) { await ConfigUtils.SetConfigurationControllerAsync( - Context.Enterprise.Auth, configuration.Uid, facade.ControllerUid); + Context.Enterprise.Auth, configuration.Uid, afterEdit.ControllerUid); } - if (!string.Equals(facade.FolderUid, origSharedFolderUid, StringComparison.Ordinal) - && !string.IsNullOrEmpty(facade.FolderUid)) + if (!string.Equals(afterEdit.FolderUid, origSharedFolderUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(afterEdit.FolderUid)) { - await MoveRecordToSharedFolderAsync(vault, configuration, facade.FolderUid); + await MoveRecordToSharedFolderAsync(vault, configuration, afterEdit.FolderUid); } - if (HasTunnelingOptions(options) || !string.Equals(facade.AdminCredentialRef, origAdminCredRef, StringComparison.Ordinal)) + if (HasTunnelingOptions(options) || !string.Equals(afterEdit.AdminCredentialRef, origAdminCredRef, StringComparison.Ordinal)) { await ConfigureTunnelingIfNeededAsync(configuration.Uid, options); } @@ -423,18 +462,6 @@ await ConfigUtils.ConfigureTunnelingAsync( ConfigUtils.ParseTriState(options.AiTerminateSessionOnDetection)); } - private async Task EnsureConfigurationNetworkGraphAsync(string configUid) - { - try - { - await ConfigUtils.EnsureConfigurationNetworkGraphAsync(Context.Enterprise.Auth, configUid); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Could not register PAM configuration network graph: {ex.Message}"); - } - } - private static bool HasTunnelingOptions(PamConfigOptions options) { return options.Connections != null || options.Tunneling != null || options.Rotation != null @@ -443,14 +470,11 @@ private static bool HasTunnelingOptions(PamConfigOptions options) || options.AiTerminateSessionOnDetection != null; } - private static Dictionary BuildConfigListJson(VaultOnline vault, TypedRecord config, bool verbose) + private static Dictionary BuildConfigListJson( + TypedRecord config, + SharedFolder sharedFolder, + bool verbose) { - var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); - if (sharedFolder == null) - { - return null; - } - var facade = new PamConfigurationFacade(config); var row = new Dictionary { @@ -500,14 +524,11 @@ private static Dictionary BuildConfigDetailJson(VaultOnline vaul return row; } - private static object[] BuildConfigTableRow(VaultOnline vault, TypedRecord config, bool verbose) + private static object[] BuildConfigTableRow( + TypedRecord config, + SharedFolder sharedFolder, + bool verbose) { - var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); - if (sharedFolder == null) - { - return null; - } - var facade = new PamConfigurationFacade(config); var row = new List { diff --git a/Commander/PAM/PamConfigEditService.cs b/Commander/PAM/PamConfigEditService.cs index 44adec59..20a66770 100644 --- a/Commander/PAM/PamConfigEditService.cs +++ b/Commander/PAM/PamConfigEditService.cs @@ -210,43 +210,83 @@ private void ApplyDomainProperties( 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) + if (options.DomainHostname == null && options.DomainPort == null) { - var existing = PamConfigFieldAssigner.GetPamHostname(record); - var host = options.DomainHostname != null - ? options.DomainHostname.Trim() - : existing?.HostName ?? ""; - var port = options.DomainPort != null - ? options.DomainPort.Trim() - : existing?.Port ?? ""; - if (!string.IsNullOrEmpty(host) || !string.IsNullOrEmpty(port)) - { - properties.Add($"f.pamHostname=$JSON:{{\"hostName\":\"{EscapeJson(host)}\",\"port\":\"{EscapeJson(port)}\"}}"); - } - else - { - properties.Add("f.pamHostname="); - } + 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 { - var host = options.DomainHostname?.Trim() ?? ""; - var port = options.DomainPort?.Trim() ?? ""; - if (!string.IsNullOrEmpty(host) || !string.IsNullOrEmpty(port)) + host = options.DomainHostname?.Trim() ?? ""; + port = options.DomainPort?.Trim() ?? ""; + if (string.IsNullOrEmpty(host) && string.IsNullOrEmpty(port)) { - properties.Add($"f.pamHostname=$JSON:{{\"hostName\":\"{EscapeJson(host)}\",\"port\":\"{EscapeJson(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) { @@ -258,18 +298,6 @@ private void ApplyDomainProperties( { PamConfigFieldAssigner.SetCheckboxField(record, "scanDCCIDR", scanDc); } - - if (DomainKwargSupplied(options.DomainNetworkCidr, isEdit)) - { - properties.Add($"text.networkCIDR={options.DomainNetworkCidr ?? ""}"); - } - - if (DomainKwargSupplied(options.DomainUserMatch, isEdit)) - { - properties.Add($"text.userMatch={options.DomainUserMatch ?? ""}"); - } - - ApplyDomainAdminCredential(record, options); } private void ApplyDomainAdminCredential(TypedRecord record, PamConfigOptions options) diff --git a/KeeperSdk/plugins/PAM/PamVaultHelpers.cs b/KeeperSdk/plugins/PAM/PamVaultHelpers.cs index 95b9e8e6..f46673b9 100644 --- a/KeeperSdk/plugins/PAM/PamVaultHelpers.cs +++ b/KeeperSdk/plugins/PAM/PamVaultHelpers.cs @@ -26,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; } @@ -46,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(); diff --git a/KeeperSdk/vault/RecordTypes.cs b/KeeperSdk/vault/RecordTypes.cs index bcf97a20..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; @@ -1425,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"), From e2379b1342c4584e4d867486ab86073857c7933a Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Thu, 16 Jul 2026 11:24:52 +0530 Subject: [PATCH 04/10] Added a message for PAM Github configration. --- Commander/PAM/PamConfigCommand.cs | 10 +++++----- KeeperSdk/plugins/PAM/ConfigUtils.cs | 20 ++++++++++++++++++++ KeeperSdk/plugins/PAM/PamRecordTypes.cs | 3 ++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Commander/PAM/PamConfigCommand.cs b/Commander/PAM/PamConfigCommand.cs index ce4adc53..81481a0a 100644 --- a/Commander/PAM/PamConfigCommand.cs +++ b/Commander/PAM/PamConfigCommand.cs @@ -205,9 +205,9 @@ private async Task NewConfigurationAsync(PamConfigOptions options) $"--environment parameter is required. Supported options: {PamConfigTypes.GetSupportedConfigTypes()}"); } - if (string.Equals(options.Environment?.Trim(), PamConfigTypes.EnvironmentOci, StringComparison.OrdinalIgnoreCase)) + if (PamConfigTypes.IsComingSoonEnvironment(options.Environment, out var comingSoonName)) { - Console.WriteLine("Environment OCI is not supported yet. It will be supported in a future release."); + Console.WriteLine($"Environment {comingSoonName} is not supported yet. It will be supported in a future release."); return; } @@ -292,9 +292,9 @@ private async Task EditConfigurationAsync(PamConfigOptions options) throw new InvalidOperationException($"PAM configuration \"{options.Uid}\" not found"); } - if (string.Equals(options.Environment?.Trim(), PamConfigTypes.EnvironmentOci, StringComparison.OrdinalIgnoreCase)) + if (PamConfigTypes.IsComingSoonEnvironment(options.Environment, out var comingSoonName)) { - Console.WriteLine("Environment OCI is not supported yet. It will be supported in a future release."); + Console.WriteLine($"Environment {comingSoonName} is not supported yet. It will be supported in a future release."); return; } @@ -586,7 +586,7 @@ internal class PamConfigOptions : EnterpriseGenericOptions [Option("config", Required = false, HelpText = "Specific PAM Configuration UID (list)")] public string ListConfig { get; set; } - [Option("environment", Required = false, HelpText = "PAM configuration type: local, aws, azure, gcp, domain, oci")] + [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")] diff --git a/KeeperSdk/plugins/PAM/ConfigUtils.cs b/KeeperSdk/plugins/PAM/ConfigUtils.cs index 73fc29f4..4966048d 100644 --- a/KeeperSdk/plugins/PAM/ConfigUtils.cs +++ b/KeeperSdk/plugins/PAM/ConfigUtils.cs @@ -20,6 +20,7 @@ public static class PamConfigTypes 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) @@ -31,6 +32,14 @@ public static class PamConfigTypes [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) @@ -44,6 +53,17 @@ public static bool TryResolveRecordType(string configType, out string recordType 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); diff --git a/KeeperSdk/plugins/PAM/PamRecordTypes.cs b/KeeperSdk/plugins/PAM/PamRecordTypes.cs index 03e3f79d..8395dd46 100644 --- a/KeeperSdk/plugins/PAM/PamRecordTypes.cs +++ b/KeeperSdk/plugins/PAM/PamRecordTypes.cs @@ -31,7 +31,8 @@ public static class PamRecordTypes "pamGcpConfiguration", "pamDomainConfiguration", "pamNetworkConfiguration", - "pamOciConfiguration"); + "pamOciConfiguration", + "pamGitHubConfiguration"); private static HashSet Create(params string[] types) { From 3a8bcb9179ef5df0b6507472168af62c63ff9523 Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Mon, 13 Jul 2026 18:10:21 +0530 Subject: [PATCH 05/10] Added support for PAM Config Command in DotNet SDK and CLI. --- Commander/PAM/PamCommandBase.cs | 148 ++-- Commander/PAM/PamConfigCommand.cs | 719 ++++++++++++++++++ Commander/PAM/PamConfigEditService.cs | 367 +++++++++ Commander/PAM/PamConfigFieldAssigner.cs | 498 ++++++++++++ Commander/PAM/PamConfigFieldDiagnostics.cs | 210 +++++ Commander/PAM/PamConfigScheduleHelper.cs | 325 ++++++++ Commander/PAM/PamConfigTunnelingHelper.cs | 31 + Commander/enterprise/EnterpriseCommands.cs | 10 + KeeperSdk/plugins/PAM/ConfigUtils.cs | 400 ++++++++++ .../plugins/PAM/PamConfigurationFacade.cs | 130 ++++ KeeperSdk/plugins/PAM/PamVaultHelpers.cs | 395 +++++++++- KeeperSdk/utils/RecordTypesUtils.cs | 2 + KeeperSdk/vault/RecordTypes.cs | 19 +- KeeperSdk/vault/SyncDownRest.cs | 74 ++ KeeperSdk/vault/VaultData.cs | 26 +- KeeperSdk/vault/VaultOnline.cs | 9 + KeeperSdk/vault/VaultOnlineFunctions.cs | 11 +- KeeperSdk/vault/VaultStorage.cs | 10 + 18 files changed, 3303 insertions(+), 81 deletions(-) create mode 100644 Commander/PAM/PamConfigCommand.cs create mode 100644 Commander/PAM/PamConfigEditService.cs create mode 100644 Commander/PAM/PamConfigFieldAssigner.cs create mode 100644 Commander/PAM/PamConfigFieldDiagnostics.cs create mode 100644 Commander/PAM/PamConfigScheduleHelper.cs create mode 100644 Commander/PAM/PamConfigTunnelingHelper.cs create mode 100644 KeeperSdk/plugins/PAM/ConfigUtils.cs create mode 100644 KeeperSdk/plugins/PAM/PamConfigurationFacade.cs 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..619c1561 --- /dev/null +++ b/Commander/PAM/PamConfigCommand.cs @@ -0,0 +1,719 @@ +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": + await EditConfigurationAsync(options); + break; + case "remove": + case "rm": + case "delete": + await RemoveConfigurationAsync(options); + break; + default: + Console.WriteLine("Unsupported command. Available: list, new, edit, remove"); + break; + } + } + + private async Task ListConfigurationsAsync(PamConfigOptions options) + { + var vault = RequireVault(); + if (!await EnsurePluginAsync(syncIfNeeded: false)) + { + return; + } + + var configId = options.ListConfig ?? options.Uid; + if (!string.IsNullOrWhiteSpace(configId)) + { + await ListSingleConfigurationAsync(vault, options, configId); + return; + } + + var configs = PamVaultHelpers.GetConfigurationRecords(vault).Values + .OrderBy(x => x.Title ?? "", StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (options.isFormatOutputJSON) + { + var rows = new List>(); + foreach (var config in configs) + { + if (!PamVaultHelpers.IsConfigurationInSharedFolder(vault, config)) + { + PamVaultHelpers.WarnConfigurationNotInSharedFolder(config); + continue; + } + + var row = BuildConfigListJson(vault, config, options.Verbose); + if (row != null) + { + rows.Add(row); + } + } + + Console.WriteLine(Json.WriteFormatted(new Dictionary { ["configurations"] = rows })); + return; + } + + var headers = new List + { + "UID", "Config Name", "Config Type", "Shared Folder", "Gateway UID", "Resource Record UIDs" + }; + if (options.Verbose) + { + headers.Add("Fields"); + } + + var tab = new Tabulate(headers.Count); + tab.AddHeader(headers.ToArray()); + foreach (var config in configs) + { + if (!PamVaultHelpers.IsConfigurationInSharedFolder(vault, config)) + { + PamVaultHelpers.WarnConfigurationNotInSharedFolder(config); + continue; + } + + var row = BuildConfigTableRow(vault, config, options.Verbose); + if (row != null) + { + tab.AddRow(row); + } + } + + tab.Dump(); + } + + 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 (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); + PamConfigFieldDiagnostics.LogPlacement(record, vault, "before-save-new"); + + await ConfigUtils.AddConfigurationRecordAsync(vault, record); + await EnsureConfigurationNetworkGraphAsync(record.Uid); + await ConfigureTunnelingIfNeededAsync(record.Uid, options); + + await MoveRecordToSharedFolderAsync(vault, record, moveDestinationUid); + await vault.SyncDown(); + + if (!string.IsNullOrEmpty(facade.ControllerUid)) + { + await ConfigUtils.SetConfigurationControllerAsync(Context.Enterprise.Auth, record.Uid, facade.ControllerUid); + } + + await vault.SyncDown(); + await SyncPamAsync(reload: true); + 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"); + } + + 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 facade = new PamConfigurationFacade(configuration); + var origGatewayUid = facade.ControllerUid; + var origSharedFolderUid = facade.FolderUid; + var origAdminCredRef = facade.AdminCredentialRef; + + var editService = CreateEditService(vault); + editService.ClearWarnings(); + editService.ApplyProperties(configuration, options, isEdit: true); + editService.VerifyRequired(configuration); + PamConfigFieldPlacement.EnsureSchemaFields(vault, configuration); + PamConfigFieldPlacement.RelocateCustomToFields(vault, configuration); + PamConfigFieldDiagnostics.LogPlacement(configuration, vault, "before-save-edit"); + await vault.UpdateRecord(configuration); + + facade = new PamConfigurationFacade(configuration); + if (!string.Equals(facade.ControllerUid, origGatewayUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(facade.ControllerUid)) + { + await ConfigUtils.SetConfigurationControllerAsync( + Context.Enterprise.Auth, configuration.Uid, facade.ControllerUid); + } + + if (!string.Equals(facade.FolderUid, origSharedFolderUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(facade.FolderUid)) + { + await MoveRecordToSharedFolderAsync(vault, configuration, facade.FolderUid); + } + + if (HasTunnelingOptions(options) || !string.Equals(facade.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 async Task EnsureConfigurationNetworkGraphAsync(string configUid) + { + try + { + await ConfigUtils.EnsureConfigurationNetworkGraphAsync(Context.Enterprise.Auth, configUid); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not register PAM configuration network graph: {ex.Message}"); + } + } + + 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(VaultOnline vault, TypedRecord config, bool verbose) + { + var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); + if (sharedFolder == null) + { + return null; + } + + 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(VaultOnline vault, TypedRecord config, bool verbose) + { + var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); + if (sharedFolder == null) + { + return null; + } + + 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 (edit/remove)")] + public string Uid { get; set; } + + [Option("config", Required = false, HelpText = "Specific PAM Configuration UID (list)")] + public string ListConfig { get; set; } + + [Option("environment", Required = false, HelpText = "PAM configuration type: local, aws, azure, gcp, domain, oci")] + 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 expression")] + 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..44adec59 --- /dev/null +++ b/Commander/PAM/PamConfigEditService.cs @@ -0,0 +1,367 @@ +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) + { + if (DomainKwargSupplied(options.DomainId, isEdit)) + { + properties.Add($"text.pamDomainId={options.DomainId ?? ""}"); + } + + if (isEdit) + { + if (options.DomainHostname != null || options.DomainPort != null) + { + var existing = PamConfigFieldAssigner.GetPamHostname(record); + var host = options.DomainHostname != null + ? options.DomainHostname.Trim() + : existing?.HostName ?? ""; + var port = options.DomainPort != null + ? options.DomainPort.Trim() + : existing?.Port ?? ""; + if (!string.IsNullOrEmpty(host) || !string.IsNullOrEmpty(port)) + { + properties.Add($"f.pamHostname=$JSON:{{\"hostName\":\"{EscapeJson(host)}\",\"port\":\"{EscapeJson(port)}\"}}"); + } + else + { + properties.Add("f.pamHostname="); + } + } + } + else + { + var host = options.DomainHostname?.Trim() ?? ""; + var port = options.DomainPort?.Trim() ?? ""; + if (!string.IsNullOrEmpty(host) || !string.IsNullOrEmpty(port)) + { + properties.Add($"f.pamHostname=$JSON:{{\"hostName\":\"{EscapeJson(host)}\",\"port\":\"{EscapeJson(port)}\"}}"); + } + } + + 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); + } + + if (DomainKwargSupplied(options.DomainNetworkCidr, isEdit)) + { + properties.Add($"text.networkCIDR={options.DomainNetworkCidr ?? ""}"); + } + + if (DomainKwargSupplied(options.DomainUserMatch, isEdit)) + { + properties.Add($"text.userMatch={options.DomainUserMatch ?? ""}"); + } + + ApplyDomainAdminCredential(record, options); + } + + 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/PamConfigFieldDiagnostics.cs b/Commander/PAM/PamConfigFieldDiagnostics.cs new file mode 100644 index 00000000..6cd4fced --- /dev/null +++ b/Commander/PAM/PamConfigFieldDiagnostics.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using KeeperSecurity.Utils; +using KeeperSecurity.Vault; + +namespace Commander.PAM +{ + /// + /// Reports whether PAM config field values live in fields[] vs custom[] (Web Vault reads fields[]). + /// + internal static class PamConfigFieldDiagnostics + { + public static void LogPlacement(TypedRecord record, VaultData vault, string context) + { + if (record == null) + { + return; + } + + Console.WriteLine($"[pam-config fields] {context}: uid={record.Uid}, type={record.TypeName}, client_modified={record.ClientModified:u}"); + Console.WriteLine($"[pam-config fields] {context}: fields[]={record.Fields.Count}, custom[]={record.Custom.Count}"); + + var schemaKeys = GetSchemaFieldKeys(vault, record.TypeName); + if (schemaKeys != null) + { + Console.WriteLine($"[pam-config fields] {context}: record-type schema has {schemaKeys.Count} field slot(s)"); + } + + foreach (var entry in DescribeAllFields(record, vault)) + { + Console.WriteLine($"[pam-config fields] {context}: {FormatEntry(entry)}"); + } + + var hiddenFromUi = DescribeAllFields(record, vault) + .Where(x => x.HasValue && x.Section == "custom" && x.MatchesSchema) + .ToList(); + if (hiddenFromUi.Count > 0) + { + Console.WriteLine( + $"[pam-config fields] {context}: WEB VAULT LIKELY HIDES {hiddenFromUi.Count} value(s) stored in custom[] " + + "(Commander list still shows them): " + + string.Join(", ", hiddenFromUi.Select(x => x.DisplayName))); + } + + var emptySchemaSlots = DescribeAllFields(record, vault) + .Where(x => x.Section == "fields" && x.MatchesSchema && !x.HasValue) + .Select(x => x.DisplayName) + .ToList(); + var valuedCustomDupes = hiddenFromUi.Select(x => x.DisplayName).ToList(); + if (emptySchemaSlots.Count > 0 && valuedCustomDupes.Count > 0 + && emptySchemaSlots.Intersect(valuedCustomDupes, StringComparer.OrdinalIgnoreCase).Any()) + { + Console.WriteLine( + $"[pam-config fields] {context}: MISPLACED DATA — empty slot in fields[] but value in custom[] " + + "(typical .NET create bug; field values may need to be recreated)"); + } + } + + public static List> BuildPlacementJson(TypedRecord record, VaultData vault) + { + return DescribeAllFields(record, vault) + .Select(x => new Dictionary + { + ["section"] = x.Section, + ["type"] = x.FieldType, + ["label"] = x.FieldLabel ?? "", + ["display_name"] = x.DisplayName, + ["schema_key"] = x.SchemaKey, + ["matches_schema"] = x.MatchesSchema, + ["has_value"] = x.HasValue, + ["web_vault_visible"] = x.WebVaultVisible, + ["value_preview"] = x.ValuePreview ?? "", + }) + .ToList(); + } + + private static HashSet GetSchemaFieldKeys(VaultData vault, string typeName) + { + if (vault == null || !vault.TryGetRecordTypeByName(typeName, out var recordType) || recordType.Fields == null) + { + return null; + } + + return new HashSet( + recordType.Fields.Select(x => x.GetTypedFieldName()), + StringComparer.OrdinalIgnoreCase); + } + + private static IEnumerable DescribeAllFields(TypedRecord record, VaultData vault) + { + var schemaKeys = GetSchemaFieldKeys(vault, record.TypeName); + foreach (var field in record.Fields) + { + yield return DescribeField(field, "fields", schemaKeys); + } + + foreach (var field in record.Custom) + { + yield return DescribeField(field, "custom", schemaKeys); + } + } + + private static FieldPlacementEntry DescribeField( + ITypedField field, + string section, + HashSet schemaKeys) + { + var schemaKey = field.GetTypedFieldName(); + var matchesSchema = schemaKeys?.Contains(schemaKey) == true; + var hasValue = FieldHasValue(field); + var preview = BuildValuePreview(field); + + return new FieldPlacementEntry + { + Section = section, + FieldType = field.FieldName ?? "", + FieldLabel = field.FieldLabel ?? "", + DisplayName = PamConfigScheduleHelper.GetPamFieldDisplayName(field), + SchemaKey = schemaKey, + MatchesSchema = matchesSchema, + HasValue = hasValue, + WebVaultVisible = section == "fields" && hasValue, + ValuePreview = preview, + }; + } + + 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 && string.IsNullOrWhiteSpace(s)) + { + continue; + } + + if (value is FieldSchedule schedule) + { + if (!string.IsNullOrWhiteSpace(schedule.Type) + && !string.Equals(schedule.Type, "ON_DEMAND", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(schedule.Type, "ON_DEMAND", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + continue; + } + + return true; + } + + return false; + } + + private static string BuildValuePreview(ITypedField field) + { + if (string.Equals(field.FieldName, "schedule", StringComparison.Ordinal)) + { + var values = PamConfigScheduleHelper.GetDisplayValues(field).ToList(); + return values.Count > 0 ? string.Join(", ", values) : ""; + } + + if (string.Equals(field.FieldName, "secret", StringComparison.Ordinal) + || string.Equals(field.FieldName, "password", StringComparison.Ordinal)) + { + return field.Count > 0 ? "***" : ""; + } + + var parts = field.GetTypedFieldInformation().ToList(); + return parts.Count > 0 ? string.Join(", ", parts) : ""; + } + + private static string FormatEntry(FieldPlacementEntry entry) + { + var storage = entry.Section == "fields" ? "fields[]" : "custom[]"; + var ui = entry.WebVaultVisible ? "web_ui=visible" : entry.HasValue ? "web_ui=hidden" : "web_ui=empty"; + var schema = entry.MatchesSchema ? "schema=match" : "schema=extra"; + var preview = string.IsNullOrEmpty(entry.ValuePreview) ? "(empty)" : entry.ValuePreview; + return $"{storage} {entry.DisplayName} [{schema}, {ui}] value={preview}"; + } + + private sealed class FieldPlacementEntry + { + public string Section { get; set; } + public string FieldType { get; set; } + public string FieldLabel { get; set; } + public string DisplayName { get; set; } + public string SchemaKey { get; set; } + public bool MatchesSchema { get; set; } + public bool HasValue { get; set; } + public bool WebVaultVisible { get; set; } + public string ValuePreview { get; set; } + } + } +} diff --git a/Commander/PAM/PamConfigScheduleHelper.cs b/Commander/PAM/PamConfigScheduleHelper.cs new file mode 100644 index 00000000..0c8ecdef --- /dev/null +++ b/Commander/PAM/PamConfigScheduleHelper.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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) + { + var cron = options.DefaultSchedule?.Trim(); + + if (isEdit && string.IsNullOrWhiteSpace(cron)) + { + SetOnDemandSchedule(record); + return; + } + + if (!string.IsNullOrWhiteSpace(cron)) + { + cron = NormalizeRotationCronForStorage(cron); + var (isValid, message) = PamCronUtils.ValidateCronExpression(cron, forRotation: true); + if (!isValid) + { + throw new InvalidOperationException($"Invalid CRON \"{cron}\" Error: {message}"); + } + + SetCronSchedule(record, cron); + } + else + { + SetOnDemandSchedule(record); + } + } + + 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; + } + + var comps = cron.Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (comps.Length >= 6) + { + return string.Join(" ", comps.Skip(1).Take(5)); + } + + return cron.Trim(); + } + + private static string NormalizeRotationCronForStorage(string cron) + { + var trimmed = cron.Trim(); + var comps = trimmed.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (comps.Length == 5) + { + return $"0 {trimmed}"; + } + + return trimmed; + } + + 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/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..136189b3 --- /dev/null +++ b/KeeperSdk/plugins/PAM/ConfigUtils.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.RegularExpressions; +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"; + + private static readonly Dictionary ConfigTypeToRecordType = + new(StringComparer.OrdinalIgnoreCase) + { + [EnvironmentAws] = "pamAwsConfiguration", + [EnvironmentAzure] = "pamAzureConfiguration", + [EnvironmentLocal] = "pamNetworkConfiguration", + [EnvironmentNetwork] = "pamNetworkConfiguration", + [EnvironmentGcp] = "pamGcpConfiguration", + [EnvironmentDomain] = "pamDomainConfiguration", + [EnvironmentOci] = "pamOciConfiguration", + }; + + 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 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 SetConfigurationControllerAsync( + IAuthentication auth, + string configurationUid, + string controllerUid) + { + if (auth == null) + { + throw new ArgumentNullException(nameof(auth)); + } + + if (string.IsNullOrEmpty(configurationUid) || string.IsNullOrEmpty(controllerUid)) + { + return; + } + + var request = new PamProto.PAMConfigurationController + { + ConfigurationUid = ByteString.CopyFrom(configurationUid.Base64UrlDecode()), + ControllerUid = ByteString.CopyFrom(controllerUid.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, + }; + } + } + + public static class PamCronUtils + { + private static readonly Regex CronFieldPattern = new( + @"^(\*|\d+L?|L[W]?|\d+-\d+|\*/\d+|\d+(,\d+)*|\d+-\d+/\d+)$", + RegexOptions.Compiled); + + public static (bool IsValid, string Message) ValidateCronExpression(string expression, bool forRotation = false) + { + if (string.IsNullOrWhiteSpace(expression)) + { + return (false, "CRON: Expression is required"); + } + + var parts = expression.Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (forRotation) + { + if (parts.Length != 6) + { + return (false, + $"CRON: Rotation schedules require all 6 parts incl. seconds - ex. Daily at 04:00:00 cron: 0 0 4 * * ? got {parts.Length} parts"); + } + + if (parts[3] != "?" && parts[5] != "?") + { + Trace.TraceWarning( + "CRON: Rotation schedule CRON format - must use ? character in one of these fields: day-of-week, day-of-month"); + } + + parts = (string[])parts.Clone(); + parts[3] = parts[3] == "?" ? "*" : parts[3]; + parts[5] = parts[5] == "?" ? "*" : parts[5]; + } + + if (parts.Length != 5 && parts.Length != 6) + { + return (false, $"CRON: Expected 5 or 6 fields, got {parts.Length}"); + } + + string minute; + string hour; + string dom; + string month; + string dow; + if (parts.Length == 6) + { + if (!ValidateCronField(parts[0], 0, 59)) + { + return (false, "CRON: Invalid seconds field"); + } + + minute = parts[1]; + hour = parts[2]; + dom = parts[3]; + month = parts[4]; + dow = parts[5]; + } + else + { + minute = parts[0]; + hour = parts[1]; + dom = parts[2]; + month = parts[3]; + dow = parts[4]; + } + + (string field, int min, int max, string name)[] validators = + { + (minute, 0, 59, "minute"), + (hour, 0, 23, "hour"), + (dom, 1, 31, "day of month"), + (month, 1, 12, "month"), + (dow, 0, 7, "day of week"), + }; + + foreach (var (field, min, max, name) in validators) + { + if (!ValidateCronField(field, min, max)) + { + return (false, $"CRON: Invalid {name} field"); + } + } + + return (true, "Valid cron expression"); + } + + private static bool ValidateCronField(string field, int minVal, int maxVal) + { + if (!CronFieldPattern.IsMatch(field)) + { + return false; + } + + foreach (var part in Regex.Split(field, @"[,\-/]")) + { + if (part == "*" || part.Length == 0 || part == "L" || part == "LW") + { + continue; + } + + var stripped = part.TrimEnd('L', 'W'); + if (stripped.Length == 0 || !int.TryParse(stripped, out var number)) + { + return false; + } + + if (number < minVal || number > maxVal) + { + return false; + } + } + + return true; + } + } +} 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/PamVaultHelpers.cs b/KeeperSdk/plugins/PAM/PamVaultHelpers.cs index f1491a51..95b9e8e6 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 { @@ -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/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..bcf97a20 100644 --- a/KeeperSdk/vault/RecordTypes.cs +++ b/KeeperSdk/vault/RecordTypes.cs @@ -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; } } /// 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, } /// From 6c43ebc6a8438714ac60a1978eaa7bfcbd101050 Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Tue, 14 Jul 2026 12:24:41 +0530 Subject: [PATCH 06/10] Fixed display field issues. --- Commander/PAM/PamConfigCommand.cs | 4 +- Commander/PAM/PamConfigFieldDiagnostics.cs | 210 --------------------- Commander/PAM/PamConfigScheduleHelper.cs | 16 +- KeeperSdk/plugins/PAM/ConfigUtils.cs | 7 - 4 files changed, 2 insertions(+), 235 deletions(-) delete mode 100644 Commander/PAM/PamConfigFieldDiagnostics.cs diff --git a/Commander/PAM/PamConfigCommand.cs b/Commander/PAM/PamConfigCommand.cs index 619c1561..dc4fcecb 100644 --- a/Commander/PAM/PamConfigCommand.cs +++ b/Commander/PAM/PamConfigCommand.cs @@ -221,7 +221,6 @@ private async Task NewConfigurationAsync(PamConfigOptions options) PamConfigFieldPlacement.EnsureSchemaFields(vault, record); PamConfigFieldPlacement.RelocateCustomToFields(vault, record); - PamConfigFieldDiagnostics.LogPlacement(record, vault, "before-save-new"); await ConfigUtils.AddConfigurationRecordAsync(vault, record); await EnsureConfigurationNetworkGraphAsync(record.Uid); @@ -290,7 +289,6 @@ private async Task EditConfigurationAsync(PamConfigOptions options) editService.VerifyRequired(configuration); PamConfigFieldPlacement.EnsureSchemaFields(vault, configuration); PamConfigFieldPlacement.RelocateCustomToFields(vault, configuration); - PamConfigFieldDiagnostics.LogPlacement(configuration, vault, "before-save-edit"); await vault.UpdateRecord(configuration); facade = new PamConfigurationFacade(configuration); @@ -579,7 +577,7 @@ internal class PamConfigOptions : EnterpriseGenericOptions [Option("shared-folder", Required = false, HelpText = "Shared folder path or UID")] public string SharedFolder { get; set; } - [Option("schedule", Required = false, HelpText = "Default schedule CRON expression")] + [Option("schedule", Required = false, HelpText = "Default schedule CRON expression (e.g. 0 0 2 * * ?)")] public string DefaultSchedule { get; set; } [Option("port-mapping", Required = false, HelpText = "Port mapping entry")] diff --git a/Commander/PAM/PamConfigFieldDiagnostics.cs b/Commander/PAM/PamConfigFieldDiagnostics.cs deleted file mode 100644 index 6cd4fced..00000000 --- a/Commander/PAM/PamConfigFieldDiagnostics.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using KeeperSecurity.Utils; -using KeeperSecurity.Vault; - -namespace Commander.PAM -{ - /// - /// Reports whether PAM config field values live in fields[] vs custom[] (Web Vault reads fields[]). - /// - internal static class PamConfigFieldDiagnostics - { - public static void LogPlacement(TypedRecord record, VaultData vault, string context) - { - if (record == null) - { - return; - } - - Console.WriteLine($"[pam-config fields] {context}: uid={record.Uid}, type={record.TypeName}, client_modified={record.ClientModified:u}"); - Console.WriteLine($"[pam-config fields] {context}: fields[]={record.Fields.Count}, custom[]={record.Custom.Count}"); - - var schemaKeys = GetSchemaFieldKeys(vault, record.TypeName); - if (schemaKeys != null) - { - Console.WriteLine($"[pam-config fields] {context}: record-type schema has {schemaKeys.Count} field slot(s)"); - } - - foreach (var entry in DescribeAllFields(record, vault)) - { - Console.WriteLine($"[pam-config fields] {context}: {FormatEntry(entry)}"); - } - - var hiddenFromUi = DescribeAllFields(record, vault) - .Where(x => x.HasValue && x.Section == "custom" && x.MatchesSchema) - .ToList(); - if (hiddenFromUi.Count > 0) - { - Console.WriteLine( - $"[pam-config fields] {context}: WEB VAULT LIKELY HIDES {hiddenFromUi.Count} value(s) stored in custom[] " + - "(Commander list still shows them): " + - string.Join(", ", hiddenFromUi.Select(x => x.DisplayName))); - } - - var emptySchemaSlots = DescribeAllFields(record, vault) - .Where(x => x.Section == "fields" && x.MatchesSchema && !x.HasValue) - .Select(x => x.DisplayName) - .ToList(); - var valuedCustomDupes = hiddenFromUi.Select(x => x.DisplayName).ToList(); - if (emptySchemaSlots.Count > 0 && valuedCustomDupes.Count > 0 - && emptySchemaSlots.Intersect(valuedCustomDupes, StringComparer.OrdinalIgnoreCase).Any()) - { - Console.WriteLine( - $"[pam-config fields] {context}: MISPLACED DATA — empty slot in fields[] but value in custom[] " + - "(typical .NET create bug; field values may need to be recreated)"); - } - } - - public static List> BuildPlacementJson(TypedRecord record, VaultData vault) - { - return DescribeAllFields(record, vault) - .Select(x => new Dictionary - { - ["section"] = x.Section, - ["type"] = x.FieldType, - ["label"] = x.FieldLabel ?? "", - ["display_name"] = x.DisplayName, - ["schema_key"] = x.SchemaKey, - ["matches_schema"] = x.MatchesSchema, - ["has_value"] = x.HasValue, - ["web_vault_visible"] = x.WebVaultVisible, - ["value_preview"] = x.ValuePreview ?? "", - }) - .ToList(); - } - - private static HashSet GetSchemaFieldKeys(VaultData vault, string typeName) - { - if (vault == null || !vault.TryGetRecordTypeByName(typeName, out var recordType) || recordType.Fields == null) - { - return null; - } - - return new HashSet( - recordType.Fields.Select(x => x.GetTypedFieldName()), - StringComparer.OrdinalIgnoreCase); - } - - private static IEnumerable DescribeAllFields(TypedRecord record, VaultData vault) - { - var schemaKeys = GetSchemaFieldKeys(vault, record.TypeName); - foreach (var field in record.Fields) - { - yield return DescribeField(field, "fields", schemaKeys); - } - - foreach (var field in record.Custom) - { - yield return DescribeField(field, "custom", schemaKeys); - } - } - - private static FieldPlacementEntry DescribeField( - ITypedField field, - string section, - HashSet schemaKeys) - { - var schemaKey = field.GetTypedFieldName(); - var matchesSchema = schemaKeys?.Contains(schemaKey) == true; - var hasValue = FieldHasValue(field); - var preview = BuildValuePreview(field); - - return new FieldPlacementEntry - { - Section = section, - FieldType = field.FieldName ?? "", - FieldLabel = field.FieldLabel ?? "", - DisplayName = PamConfigScheduleHelper.GetPamFieldDisplayName(field), - SchemaKey = schemaKey, - MatchesSchema = matchesSchema, - HasValue = hasValue, - WebVaultVisible = section == "fields" && hasValue, - ValuePreview = preview, - }; - } - - 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 && string.IsNullOrWhiteSpace(s)) - { - continue; - } - - if (value is FieldSchedule schedule) - { - if (!string.IsNullOrWhiteSpace(schedule.Type) - && !string.Equals(schedule.Type, "ON_DEMAND", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (string.Equals(schedule.Type, "ON_DEMAND", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - continue; - } - - return true; - } - - return false; - } - - private static string BuildValuePreview(ITypedField field) - { - if (string.Equals(field.FieldName, "schedule", StringComparison.Ordinal)) - { - var values = PamConfigScheduleHelper.GetDisplayValues(field).ToList(); - return values.Count > 0 ? string.Join(", ", values) : ""; - } - - if (string.Equals(field.FieldName, "secret", StringComparison.Ordinal) - || string.Equals(field.FieldName, "password", StringComparison.Ordinal)) - { - return field.Count > 0 ? "***" : ""; - } - - var parts = field.GetTypedFieldInformation().ToList(); - return parts.Count > 0 ? string.Join(", ", parts) : ""; - } - - private static string FormatEntry(FieldPlacementEntry entry) - { - var storage = entry.Section == "fields" ? "fields[]" : "custom[]"; - var ui = entry.WebVaultVisible ? "web_ui=visible" : entry.HasValue ? "web_ui=hidden" : "web_ui=empty"; - var schema = entry.MatchesSchema ? "schema=match" : "schema=extra"; - var preview = string.IsNullOrEmpty(entry.ValuePreview) ? "(empty)" : entry.ValuePreview; - return $"{storage} {entry.DisplayName} [{schema}, {ui}] value={preview}"; - } - - private sealed class FieldPlacementEntry - { - public string Section { get; set; } - public string FieldType { get; set; } - public string FieldLabel { get; set; } - public string DisplayName { get; set; } - public string SchemaKey { get; set; } - public bool MatchesSchema { get; set; } - public bool HasValue { get; set; } - public bool WebVaultVisible { get; set; } - public string ValuePreview { get; set; } - } - } -} diff --git a/Commander/PAM/PamConfigScheduleHelper.cs b/Commander/PAM/PamConfigScheduleHelper.cs index 0c8ecdef..41fd3383 100644 --- a/Commander/PAM/PamConfigScheduleHelper.cs +++ b/Commander/PAM/PamConfigScheduleHelper.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Text; using KeeperSecurity.Plugins.PAM; using KeeperSecurity.Utils; @@ -187,25 +186,12 @@ private static string ExportCronScheduleField(string cron) return null; } - var comps = cron.Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); - if (comps.Length >= 6) - { - return string.Join(" ", comps.Skip(1).Take(5)); - } - return cron.Trim(); } private static string NormalizeRotationCronForStorage(string cron) { - var trimmed = cron.Trim(); - var comps = trimmed.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); - if (comps.Length == 5) - { - return $"0 {trimmed}"; - } - - return trimmed; + return cron?.Trim(); } private static FieldSchedule NormalizeSchedule(FieldSchedule source) diff --git a/KeeperSdk/plugins/PAM/ConfigUtils.cs b/KeeperSdk/plugins/PAM/ConfigUtils.cs index 136189b3..73fc29f4 100644 --- a/KeeperSdk/plugins/PAM/ConfigUtils.cs +++ b/KeeperSdk/plugins/PAM/ConfigUtils.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Text.RegularExpressions; using System.Threading.Tasks; using Google.Protobuf; @@ -305,12 +304,6 @@ public static (bool IsValid, string Message) ValidateCronExpression(string expre $"CRON: Rotation schedules require all 6 parts incl. seconds - ex. Daily at 04:00:00 cron: 0 0 4 * * ? got {parts.Length} parts"); } - if (parts[3] != "?" && parts[5] != "?") - { - Trace.TraceWarning( - "CRON: Rotation schedule CRON format - must use ? character in one of these fields: day-of-week, day-of-month"); - } - parts = (string[])parts.Clone(); parts[3] = parts[3] == "?" ? "*" : parts[3]; parts[5] = parts[5] == "?" ? "*" : parts[5]; From 8f22db27f43e391f89489a9d35ea61e35d31be4d Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Wed, 15 Jul 2026 17:51:36 +0530 Subject: [PATCH 07/10] Reviewed changes --- Commander/PAM/PamConfigCommand.cs | 157 +++++++++++++---------- Commander/PAM/PamConfigEditService.cs | 92 ++++++++----- KeeperSdk/plugins/PAM/PamVaultHelpers.cs | 6 +- KeeperSdk/vault/RecordTypes.cs | 3 +- 4 files changed, 154 insertions(+), 104 deletions(-) diff --git a/Commander/PAM/PamConfigCommand.cs b/Commander/PAM/PamConfigCommand.cs index dc4fcecb..ce4adc53 100644 --- a/Commander/PAM/PamConfigCommand.cs +++ b/Commander/PAM/PamConfigCommand.cs @@ -38,16 +38,25 @@ public async Task ExecuteAsync(PamConfigOptions 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: - Console.WriteLine("Unsupported command. Available: list, new, edit, remove"); - break; + throw new InvalidOperationException("Unsupported command. Available: list, new, edit, remove"); } } @@ -67,36 +76,48 @@ private async Task ListConfigurationsAsync(PamConfigOptions options) } var configs = PamVaultHelpers.GetConfigurationRecords(vault).Values - .OrderBy(x => x.Title ?? "", StringComparer.OrdinalIgnoreCase) - .ToList(); + .OrderBy(x => x.Title ?? string.Empty, StringComparer.OrdinalIgnoreCase); if (options.isFormatOutputJSON) { - var rows = new List>(); - foreach (var config in configs) - { - if (!PamVaultHelpers.IsConfigurationInSharedFolder(vault, config)) - { - PamVaultHelpers.WarnConfigurationNotInSharedFolder(config); - continue; - } + ListConfigurationsAsJson(vault, configs, options.Verbose); + } + else + { + ListConfigurationsAsTable(vault, configs, options.Verbose); + } + } - var row = BuildConfigListJson(vault, config, options.Verbose); - if (row != null) - { - rows.Add(row); - } + 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; } - Console.WriteLine(Json.WriteFormatted(new Dictionary { ["configurations"] = rows })); - return; + 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 (options.Verbose) + if (verbose) { headers.Add("Fields"); } @@ -105,22 +126,29 @@ private async Task ListConfigurationsAsync(PamConfigOptions options) tab.AddHeader(headers.ToArray()); foreach (var config in configs) { - if (!PamVaultHelpers.IsConfigurationInSharedFolder(vault, config)) + var sharedFolder = TryGetListedConfigurationFolder(vault, config); + if (sharedFolder == null) { - PamVaultHelpers.WarnConfigurationNotInSharedFolder(config); continue; } - var row = BuildConfigTableRow(vault, config, options.Verbose); - if (row != null) - { - tab.AddRow(row); - } + 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); @@ -177,6 +205,12 @@ private async Task NewConfigurationAsync(PamConfigOptions options) $"--environment parameter is required. Supported options: {PamConfigTypes.GetSupportedConfigTypes()}"); } + if (string.Equals(options.Environment?.Trim(), PamConfigTypes.EnvironmentOci, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Environment OCI 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"); @@ -223,11 +257,11 @@ private async Task NewConfigurationAsync(PamConfigOptions options) PamConfigFieldPlacement.RelocateCustomToFields(vault, record); await ConfigUtils.AddConfigurationRecordAsync(vault, record); - await EnsureConfigurationNetworkGraphAsync(record.Uid); + await ConfigUtils.EnsureConfigurationNetworkGraphAsync(Context.Enterprise.Auth, record.Uid); await ConfigureTunnelingIfNeededAsync(record.Uid, options); - await MoveRecordToSharedFolderAsync(vault, record, moveDestinationUid); await vault.SyncDown(); + await MoveRecordToSharedFolderAsync(vault, record, moveDestinationUid); if (!string.IsNullOrEmpty(facade.ControllerUid)) { @@ -235,7 +269,6 @@ private async Task NewConfigurationAsync(PamConfigOptions options) } await vault.SyncDown(); - await SyncPamAsync(reload: true); editService.LogWarnings(); Console.WriteLine(record.Uid); } @@ -259,6 +292,12 @@ private async Task EditConfigurationAsync(PamConfigOptions options) throw new InvalidOperationException($"PAM configuration \"{options.Uid}\" not found"); } + if (string.Equals(options.Environment?.Trim(), PamConfigTypes.EnvironmentOci, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Environment OCI is not supported yet. It will be supported in a future release."); + return; + } + await vault.EnsurePamRecordTypesAsync(); if (!string.IsNullOrWhiteSpace(options.Environment) @@ -278,10 +317,10 @@ private async Task EditConfigurationAsync(PamConfigOptions options) configuration.Title = options.Title.Trim(); } - var facade = new PamConfigurationFacade(configuration); - var origGatewayUid = facade.ControllerUid; - var origSharedFolderUid = facade.FolderUid; - var origAdminCredRef = facade.AdminCredentialRef; + var beforeEdit = new PamConfigurationFacade(configuration); + var origGatewayUid = beforeEdit.ControllerUid; + var origSharedFolderUid = beforeEdit.FolderUid; + var origAdminCredRef = beforeEdit.AdminCredentialRef; var editService = CreateEditService(vault); editService.ClearWarnings(); @@ -291,21 +330,21 @@ private async Task EditConfigurationAsync(PamConfigOptions options) PamConfigFieldPlacement.RelocateCustomToFields(vault, configuration); await vault.UpdateRecord(configuration); - facade = new PamConfigurationFacade(configuration); - if (!string.Equals(facade.ControllerUid, origGatewayUid, StringComparison.Ordinal) - && !string.IsNullOrEmpty(facade.ControllerUid)) + var afterEdit = new PamConfigurationFacade(configuration); + if (!string.Equals(afterEdit.ControllerUid, origGatewayUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(afterEdit.ControllerUid)) { await ConfigUtils.SetConfigurationControllerAsync( - Context.Enterprise.Auth, configuration.Uid, facade.ControllerUid); + Context.Enterprise.Auth, configuration.Uid, afterEdit.ControllerUid); } - if (!string.Equals(facade.FolderUid, origSharedFolderUid, StringComparison.Ordinal) - && !string.IsNullOrEmpty(facade.FolderUid)) + if (!string.Equals(afterEdit.FolderUid, origSharedFolderUid, StringComparison.Ordinal) + && !string.IsNullOrEmpty(afterEdit.FolderUid)) { - await MoveRecordToSharedFolderAsync(vault, configuration, facade.FolderUid); + await MoveRecordToSharedFolderAsync(vault, configuration, afterEdit.FolderUid); } - if (HasTunnelingOptions(options) || !string.Equals(facade.AdminCredentialRef, origAdminCredRef, StringComparison.Ordinal)) + if (HasTunnelingOptions(options) || !string.Equals(afterEdit.AdminCredentialRef, origAdminCredRef, StringComparison.Ordinal)) { await ConfigureTunnelingIfNeededAsync(configuration.Uid, options); } @@ -423,18 +462,6 @@ await ConfigUtils.ConfigureTunnelingAsync( ConfigUtils.ParseTriState(options.AiTerminateSessionOnDetection)); } - private async Task EnsureConfigurationNetworkGraphAsync(string configUid) - { - try - { - await ConfigUtils.EnsureConfigurationNetworkGraphAsync(Context.Enterprise.Auth, configUid); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Could not register PAM configuration network graph: {ex.Message}"); - } - } - private static bool HasTunnelingOptions(PamConfigOptions options) { return options.Connections != null || options.Tunneling != null || options.Rotation != null @@ -443,14 +470,11 @@ private static bool HasTunnelingOptions(PamConfigOptions options) || options.AiTerminateSessionOnDetection != null; } - private static Dictionary BuildConfigListJson(VaultOnline vault, TypedRecord config, bool verbose) + private static Dictionary BuildConfigListJson( + TypedRecord config, + SharedFolder sharedFolder, + bool verbose) { - var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); - if (sharedFolder == null) - { - return null; - } - var facade = new PamConfigurationFacade(config); var row = new Dictionary { @@ -500,14 +524,11 @@ private static Dictionary BuildConfigDetailJson(VaultOnline vaul return row; } - private static object[] BuildConfigTableRow(VaultOnline vault, TypedRecord config, bool verbose) + private static object[] BuildConfigTableRow( + TypedRecord config, + SharedFolder sharedFolder, + bool verbose) { - var sharedFolder = PamVaultHelpers.GetConfigurationSharedFolder(vault, config); - if (sharedFolder == null) - { - return null; - } - var facade = new PamConfigurationFacade(config); var row = new List { diff --git a/Commander/PAM/PamConfigEditService.cs b/Commander/PAM/PamConfigEditService.cs index 44adec59..20a66770 100644 --- a/Commander/PAM/PamConfigEditService.cs +++ b/Commander/PAM/PamConfigEditService.cs @@ -210,43 +210,83 @@ private void ApplyDomainProperties( 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) + if (options.DomainHostname == null && options.DomainPort == null) { - var existing = PamConfigFieldAssigner.GetPamHostname(record); - var host = options.DomainHostname != null - ? options.DomainHostname.Trim() - : existing?.HostName ?? ""; - var port = options.DomainPort != null - ? options.DomainPort.Trim() - : existing?.Port ?? ""; - if (!string.IsNullOrEmpty(host) || !string.IsNullOrEmpty(port)) - { - properties.Add($"f.pamHostname=$JSON:{{\"hostName\":\"{EscapeJson(host)}\",\"port\":\"{EscapeJson(port)}\"}}"); - } - else - { - properties.Add("f.pamHostname="); - } + 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 { - var host = options.DomainHostname?.Trim() ?? ""; - var port = options.DomainPort?.Trim() ?? ""; - if (!string.IsNullOrEmpty(host) || !string.IsNullOrEmpty(port)) + host = options.DomainHostname?.Trim() ?? ""; + port = options.DomainPort?.Trim() ?? ""; + if (string.IsNullOrEmpty(host) && string.IsNullOrEmpty(port)) { - properties.Add($"f.pamHostname=$JSON:{{\"hostName\":\"{EscapeJson(host)}\",\"port\":\"{EscapeJson(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) { @@ -258,18 +298,6 @@ private void ApplyDomainProperties( { PamConfigFieldAssigner.SetCheckboxField(record, "scanDCCIDR", scanDc); } - - if (DomainKwargSupplied(options.DomainNetworkCidr, isEdit)) - { - properties.Add($"text.networkCIDR={options.DomainNetworkCidr ?? ""}"); - } - - if (DomainKwargSupplied(options.DomainUserMatch, isEdit)) - { - properties.Add($"text.userMatch={options.DomainUserMatch ?? ""}"); - } - - ApplyDomainAdminCredential(record, options); } private void ApplyDomainAdminCredential(TypedRecord record, PamConfigOptions options) diff --git a/KeeperSdk/plugins/PAM/PamVaultHelpers.cs b/KeeperSdk/plugins/PAM/PamVaultHelpers.cs index 95b9e8e6..f46673b9 100644 --- a/KeeperSdk/plugins/PAM/PamVaultHelpers.cs +++ b/KeeperSdk/plugins/PAM/PamVaultHelpers.cs @@ -26,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; } @@ -46,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(); diff --git a/KeeperSdk/vault/RecordTypes.cs b/KeeperSdk/vault/RecordTypes.cs index bcf97a20..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; @@ -1425,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"), From 9f8f04b8c265885e1ad2f0eb62326a103ac9d3ec Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Thu, 16 Jul 2026 11:24:52 +0530 Subject: [PATCH 08/10] Added a message for PAM Github configration. --- Commander/PAM/PamConfigCommand.cs | 10 +++++----- KeeperSdk/plugins/PAM/ConfigUtils.cs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Commander/PAM/PamConfigCommand.cs b/Commander/PAM/PamConfigCommand.cs index ce4adc53..81481a0a 100644 --- a/Commander/PAM/PamConfigCommand.cs +++ b/Commander/PAM/PamConfigCommand.cs @@ -205,9 +205,9 @@ private async Task NewConfigurationAsync(PamConfigOptions options) $"--environment parameter is required. Supported options: {PamConfigTypes.GetSupportedConfigTypes()}"); } - if (string.Equals(options.Environment?.Trim(), PamConfigTypes.EnvironmentOci, StringComparison.OrdinalIgnoreCase)) + if (PamConfigTypes.IsComingSoonEnvironment(options.Environment, out var comingSoonName)) { - Console.WriteLine("Environment OCI is not supported yet. It will be supported in a future release."); + Console.WriteLine($"Environment {comingSoonName} is not supported yet. It will be supported in a future release."); return; } @@ -292,9 +292,9 @@ private async Task EditConfigurationAsync(PamConfigOptions options) throw new InvalidOperationException($"PAM configuration \"{options.Uid}\" not found"); } - if (string.Equals(options.Environment?.Trim(), PamConfigTypes.EnvironmentOci, StringComparison.OrdinalIgnoreCase)) + if (PamConfigTypes.IsComingSoonEnvironment(options.Environment, out var comingSoonName)) { - Console.WriteLine("Environment OCI is not supported yet. It will be supported in a future release."); + Console.WriteLine($"Environment {comingSoonName} is not supported yet. It will be supported in a future release."); return; } @@ -586,7 +586,7 @@ internal class PamConfigOptions : EnterpriseGenericOptions [Option("config", Required = false, HelpText = "Specific PAM Configuration UID (list)")] public string ListConfig { get; set; } - [Option("environment", Required = false, HelpText = "PAM configuration type: local, aws, azure, gcp, domain, oci")] + [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")] diff --git a/KeeperSdk/plugins/PAM/ConfigUtils.cs b/KeeperSdk/plugins/PAM/ConfigUtils.cs index 73fc29f4..4966048d 100644 --- a/KeeperSdk/plugins/PAM/ConfigUtils.cs +++ b/KeeperSdk/plugins/PAM/ConfigUtils.cs @@ -20,6 +20,7 @@ public static class PamConfigTypes 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) @@ -31,6 +32,14 @@ public static class PamConfigTypes [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) @@ -44,6 +53,17 @@ public static bool TryResolveRecordType(string configType, out string recordType 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); From 4f7a765739eb560d3f0f47e836d2425d5c94ca58 Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Mon, 27 Jul 2026 16:05:17 +0530 Subject: [PATCH 09/10] Reviewed changes --- Commander/PAM/PamConfigCommand.cs | 12 +-- Commander/PAM/PamConfigScheduleHelper.cs | 39 +++++--- Commander/PAM/PamRotationCommand.cs | 7 +- Commander/PAM/PamRotationEditService.cs | 20 ++-- KeeperSdk/plugins/PAM/ConfigUtils.cs | 116 +--------------------- KeeperSdk/plugins/PAM/PamRotationGraph.cs | 2 +- KeeperSdk/plugins/PAM/RotationUtils.cs | 7 +- 7 files changed, 48 insertions(+), 155 deletions(-) diff --git a/Commander/PAM/PamConfigCommand.cs b/Commander/PAM/PamConfigCommand.cs index 81481a0a..d5a60cbf 100644 --- a/Commander/PAM/PamConfigCommand.cs +++ b/Commander/PAM/PamConfigCommand.cs @@ -68,10 +68,9 @@ private async Task ListConfigurationsAsync(PamConfigOptions options) return; } - var configId = options.ListConfig ?? options.Uid; - if (!string.IsNullOrWhiteSpace(configId)) + if (!string.IsNullOrWhiteSpace(options.Uid)) { - await ListSingleConfigurationAsync(vault, options, configId); + await ListSingleConfigurationAsync(vault, options, options.Uid); return; } @@ -580,12 +579,9 @@ 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 (edit/remove)")] + [Value(1, Required = false, HelpText = "Configuration UID or name (list/edit/remove)")] public string Uid { get; set; } - [Option("config", Required = false, HelpText = "Specific PAM Configuration UID (list)")] - public string ListConfig { get; set; } - [Option("environment", Required = false, HelpText = "PAM configuration type: local, aws, azure, gcp, domain, oci, github")] public string Environment { get; set; } @@ -598,7 +594,7 @@ internal class PamConfigOptions : EnterpriseGenericOptions [Option("shared-folder", Required = false, HelpText = "Shared folder path or UID")] public string SharedFolder { get; set; } - [Option("schedule", Required = false, HelpText = "Default schedule CRON expression (e.g. 0 0 2 * * ?)")] + [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")] diff --git a/Commander/PAM/PamConfigScheduleHelper.cs b/Commander/PAM/PamConfigScheduleHelper.cs index 41fd3383..a7d5448b 100644 --- a/Commander/PAM/PamConfigScheduleHelper.cs +++ b/Commander/PAM/PamConfigScheduleHelper.cs @@ -16,29 +16,38 @@ internal static class PamConfigScheduleHelper public static void ApplyDefaultRotationSchedule(TypedRecord record, PamConfigOptions options, bool isEdit) { - var cron = options.DefaultSchedule?.Trim(); - - if (isEdit && string.IsNullOrWhiteSpace(cron)) - { - SetOnDemandSchedule(record); - return; - } - - if (!string.IsNullOrWhiteSpace(cron)) + // Omitted --schedule: leave existing value on edit; default On-Demand on create. + if (options.DefaultSchedule == null) { - cron = NormalizeRotationCronForStorage(cron); - var (isValid, message) = PamCronUtils.ValidateCronExpression(cron, forRotation: true); - if (!isValid) + if (!isEdit) { - throw new InvalidOperationException($"Invalid CRON \"{cron}\" Error: {message}"); + SetOnDemandSchedule(record); } - SetCronSchedule(record, cron); + return; } - else + + 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) 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/KeeperSdk/plugins/PAM/ConfigUtils.cs b/KeeperSdk/plugins/PAM/ConfigUtils.cs index 4966048d..ffffe45e 100644 --- a/KeeperSdk/plugins/PAM/ConfigUtils.cs +++ b/KeeperSdk/plugins/PAM/ConfigUtils.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; using System.Threading.Tasks; using Google.Protobuf; using KeeperSecurity.Authentication; @@ -146,14 +145,14 @@ public static async Task AddConfigurationRecordAsync(VaultOnline vault, TypedRec public static async Task SetConfigurationControllerAsync( IAuthentication auth, string configurationUid, - string controllerUid) + string gatewayUid) { if (auth == null) { throw new ArgumentNullException(nameof(auth)); } - if (string.IsNullOrEmpty(configurationUid) || string.IsNullOrEmpty(controllerUid)) + if (string.IsNullOrEmpty(configurationUid) || string.IsNullOrEmpty(gatewayUid)) { return; } @@ -161,7 +160,7 @@ public static async Task SetConfigurationControllerAsync( var request = new PamProto.PAMConfigurationController { ConfigurationUid = ByteString.CopyFrom(configurationUid.Base64UrlDecode()), - ControllerUid = ByteString.CopyFrom(controllerUid.Base64UrlDecode()), + ControllerUid = ByteString.CopyFrom(gatewayUid.Base64UrlDecode()), }; await auth.ExecuteAuthRest(SetConfigurationControllerEndpoint, request); @@ -301,113 +300,4 @@ private static void ApplyTriState( }; } } - - public static class PamCronUtils - { - private static readonly Regex CronFieldPattern = new( - @"^(\*|\d+L?|L[W]?|\d+-\d+|\*/\d+|\d+(,\d+)*|\d+-\d+/\d+)$", - RegexOptions.Compiled); - - public static (bool IsValid, string Message) ValidateCronExpression(string expression, bool forRotation = false) - { - if (string.IsNullOrWhiteSpace(expression)) - { - return (false, "CRON: Expression is required"); - } - - var parts = expression.Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); - if (forRotation) - { - if (parts.Length != 6) - { - return (false, - $"CRON: Rotation schedules require all 6 parts incl. seconds - ex. Daily at 04:00:00 cron: 0 0 4 * * ? got {parts.Length} parts"); - } - - parts = (string[])parts.Clone(); - parts[3] = parts[3] == "?" ? "*" : parts[3]; - parts[5] = parts[5] == "?" ? "*" : parts[5]; - } - - if (parts.Length != 5 && parts.Length != 6) - { - return (false, $"CRON: Expected 5 or 6 fields, got {parts.Length}"); - } - - string minute; - string hour; - string dom; - string month; - string dow; - if (parts.Length == 6) - { - if (!ValidateCronField(parts[0], 0, 59)) - { - return (false, "CRON: Invalid seconds field"); - } - - minute = parts[1]; - hour = parts[2]; - dom = parts[3]; - month = parts[4]; - dow = parts[5]; - } - else - { - minute = parts[0]; - hour = parts[1]; - dom = parts[2]; - month = parts[3]; - dow = parts[4]; - } - - (string field, int min, int max, string name)[] validators = - { - (minute, 0, 59, "minute"), - (hour, 0, 23, "hour"), - (dom, 1, 31, "day of month"), - (month, 1, 12, "month"), - (dow, 0, 7, "day of week"), - }; - - foreach (var (field, min, max, name) in validators) - { - if (!ValidateCronField(field, min, max)) - { - return (false, $"CRON: Invalid {name} field"); - } - } - - return (true, "Valid cron expression"); - } - - private static bool ValidateCronField(string field, int minVal, int maxVal) - { - if (!CronFieldPattern.IsMatch(field)) - { - return false; - } - - foreach (var part in Regex.Split(field, @"[,\-/]")) - { - if (part == "*" || part.Length == 0 || part == "L" || part == "LW") - { - continue; - } - - var stripped = part.TrimEnd('L', 'W'); - if (stripped.Length == 0 || !int.TryParse(stripped, out var number)) - { - return false; - } - - if (number < minVal || number > maxVal) - { - return false; - } - } - - return true; - } - } } 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/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}"); } From 819f796d7fff489c13f4c58cd2e94f826361db2f Mon Sep 17 00:00:00 2001 From: amandli-ks Date: Mon, 27 Jul 2026 16:10:35 +0530 Subject: [PATCH 10/10] Changed name from controller to gateway. --- Commander/PAM/PamConfigCommand.cs | 4 ++-- KeeperSdk/plugins/PAM/ConfigUtils.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Commander/PAM/PamConfigCommand.cs b/Commander/PAM/PamConfigCommand.cs index d5a60cbf..b94b5c60 100644 --- a/Commander/PAM/PamConfigCommand.cs +++ b/Commander/PAM/PamConfigCommand.cs @@ -264,7 +264,7 @@ private async Task NewConfigurationAsync(PamConfigOptions options) if (!string.IsNullOrEmpty(facade.ControllerUid)) { - await ConfigUtils.SetConfigurationControllerAsync(Context.Enterprise.Auth, record.Uid, facade.ControllerUid); + await ConfigUtils.SetConfigurationGatewayAsync(Context.Enterprise.Auth, record.Uid, facade.ControllerUid); } await vault.SyncDown(); @@ -333,7 +333,7 @@ private async Task EditConfigurationAsync(PamConfigOptions options) if (!string.Equals(afterEdit.ControllerUid, origGatewayUid, StringComparison.Ordinal) && !string.IsNullOrEmpty(afterEdit.ControllerUid)) { - await ConfigUtils.SetConfigurationControllerAsync( + await ConfigUtils.SetConfigurationGatewayAsync( Context.Enterprise.Auth, configuration.Uid, afterEdit.ControllerUid); } diff --git a/KeeperSdk/plugins/PAM/ConfigUtils.cs b/KeeperSdk/plugins/PAM/ConfigUtils.cs index ffffe45e..e4c5b54c 100644 --- a/KeeperSdk/plugins/PAM/ConfigUtils.cs +++ b/KeeperSdk/plugins/PAM/ConfigUtils.cs @@ -142,7 +142,7 @@ public static async Task AddConfigurationRecordAsync(VaultOnline vault, TypedRec vault.CacheKeeperRecord(record); } - public static async Task SetConfigurationControllerAsync( + public static async Task SetConfigurationGatewayAsync( IAuthentication auth, string configurationUid, string gatewayUid)