diff --git a/CHANGELOG.md b/CHANGELOG.md index 43cab5c..c1802ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# v1.1.0 + +## Features + +- **Environment variable overrides for connection parameters** — `AuthType`, `AccessId`, and `AccessKey` can now be overridden at runtime via the `AKEYLESS_AUTH_TYPE`, `AKEYLESS_ACCESS_ID`, and `AKEYLESS_ACCESS_KEY` environment variables, matching the existing `AKEYLESS_API_URL` override. This lets deployments control Akeyless connection details at the infrastructure level instead of only via `manifest.json` or the Command portal. +- Environment variable overrides are trimmed of leading/trailing whitespace before use. +- Hardened validation and logging around connection parameters and authentication failures. + # v1.0.0 Initial release of the Akeyless PAM Provider for Keyfactor Command and Universal Orchestrator. diff --git a/README.md b/README.md index 955f6d1..7406170 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ To install Akeyless PAM Provider, it is recommended you install [kfutil](https:/ #### Requirements - Akeyless credentials w/ permission to access the secret(s) being used. See the [Akeyless documentation](https://docs.akeyless.io/reference/auth) for more information on how to configure the different types of auth. + - (Optional) `AKEYLESS_API_URL`, `AKEYLESS_AUTH_TYPE`, `AKEYLESS_ACCESS_ID`, and `AKEYLESS_ACCESS_KEY` environment variables can be set on the provider's host process to override the corresponding `manifest.json`/Command portal parameters at runtime. See [Configuration](docs/akeyless.md#configuration) for details and precedence. #### Create PAM type in Keyfactor Command diff --git a/akeyless-pam/AkeylessPam.cs b/akeyless-pam/AkeylessPam.cs index bd0ab7f..dc4d97d 100644 --- a/akeyless-pam/AkeylessPam.cs +++ b/akeyless-pam/AkeylessPam.cs @@ -135,51 +135,157 @@ public string GetPassword(Dictionary instanceParameters, } } + /// + /// Resolves an environment variable override for a connection parameter. + /// + /// The name of the environment variable to check. + /// + /// The environment variable's value, trimmed of leading/trailing whitespace, if it is set to a non-empty, + /// non-whitespace string; otherwise, null so the caller falls back to the configured value. + /// + /// + /// Unlike a plain ?? null-coalesce against , + /// this treats an env var explicitly set to an empty or whitespace-only string the same as an unset env + /// var — it does not override the configured value. This avoids a misconfigured/blank environment variable + /// silently blanking out a valid `manifest.json`/Command portal value (e.g. `Url`, `AccessId`, `AccessKey`, + /// `AuthType`). The returned value is trimmed because env vars sourced from mounted secret files/configmaps + /// commonly carry an incidental trailing newline, which would otherwise fail exact-match comparisons + /// (auth type) or authentication (AccessId/AccessKey) with no indication that whitespace was the cause. + /// + /// Audit trail: when an override is active, this logs which environment variable is overriding — + /// never the value — so an incident investigation can tell whether the effective connection + /// parameter used at runtime matches Command's recorded configuration. The resolved value is not + /// content-validated here — validates it (and the equivalent + /// configured value, when no override is active) once the caller has settled on the final value to + /// use, so the same content guarantee applies regardless of which source it came from. + /// + /// + /// Logged at Warning, not Information: a host that already has one of these AKEYLESS_-prefixed + /// variables set for an unrelated reason (e.g. a co-located Akeyless CLI install using the same + /// conventional variable names) will silently authenticate with a different identity than Command's + /// recorded configuration after upgrading to a version that reads it — with zero configuration + /// change on Command's side. That's a connection-identity change, not routine operation, so it + /// should surface to an operator without them having to go looking for it at Information level. + /// + /// + private string ResolveEnvOverride(string envVarName) + { + var value = Environment.GetEnvironmentVariable(envVarName); + if (string.IsNullOrWhiteSpace(value)) return null; + + Logger.LogWarning("Environment variable override active for {EnvVar}", envVarName); + return value.Trim(); + } + + /// + /// Validates that a resolved connection parameter contains only printable ASCII characters. + /// + /// The resolved value to validate (from either an env var override or Command's configuration). + /// The name of the parameter, used in the error message. + /// + /// None of Url, AuthType, AccessId, or AccessKey are legitimately anything + /// other than printable ASCII. Rather than maintain a growing blocklist of specific problem characters + /// (control characters, ANSI escape sequences, Unicode line/paragraph separators, bidirectional-override + /// and zero-width/variation-selector characters have all been found, one at a time, to let a value forge + /// extra log lines or render differently than its actual content in the structured log messages that + /// echo these values), this allowlists the narrow range of characters that are actually legitimate and + /// rejects everything else in one check. Called from two places so the guarantee holds no matter which + /// log statement runs first: validates the Command-/ + /// manifest.json-configured value before its own log statements echo it, and + /// separately validates the final resolved value (which may instead be an env var override) before its + /// own log statements echo that. + /// + /// + /// Thrown if the value contains any character outside the printable ASCII range (0x20-0x7E). + /// + private void EnsurePrintableAscii(string value, string parameterName) + { + if (value != null && value.All(c => c is >= (char)0x20 and <= (char)0x7E)) return; + + // Logged before throwing, like every other validation failure in this class (ValidateRequiredParameter, + // ValidateAuthTypeAccessKey, the unsupported-auth-type checks, model validation) — without this, a + // rejected log-injection/spoofing attempt would leave no audit trail at all, defeating the point of + // having caught it. The value itself is never logged, only the parameter name. + Logger.LogError( + "{ParameterName} contains a non-printable or non-ASCII character; refusing to use it", parameterName); + throw new InvalidClientConfigurationException( + $"{parameterName} contains a non-printable or non-ASCII character and cannot be used."); + } + + /// + /// Resolves a connection parameter from its env var override (if active) or its configured value, then + /// validates the result via . + /// + private string ResolveAndValidate(string envVarName, string configuredValue, string parameterName) + { + var value = ResolveEnvOverride(envVarName) ?? configuredValue; + EnsurePrintableAscii(value, parameterName); + return value; + } + private IAkeylessApiClient InitClient(AkeylessConfiguration configurationInfo) { + // Hoisted above the try block (rather than declared with the other resolved values inside it) so the + // ApiException catch below can still report which AccessId the failed authentication attempt used — + // that identity may differ from Command's recorded configuration when an env var override is active. + string accessId = null; try { Logger.MethodEntry(); - var basePath = Environment.GetEnvironmentVariable("AKEYLESS_API_URL") ?? - configurationInfo.Url ?? "https://api.akeyless.io"; + // configurationInfo.Url is always populated by BuildAkeylessConfiguration (which owns the + // default-URL fallback), so no second fallback is needed here. + var basePath = ResolveAndValidate("AKEYLESS_API_URL", configurationInfo.Url, "Url"); + Logger.LogDebug("Connecting to Akeyless at '{Url}'", basePath); + + var authType = ResolveAndValidate("AKEYLESS_AUTH_TYPE", configurationInfo.AuthType, "AuthType"); + accessId = ResolveAndValidate("AKEYLESS_ACCESS_ID", configurationInfo.AccessId, "AccessId"); + var accessKey = ResolveAndValidate("AKEYLESS_ACCESS_KEY", configurationInfo.AccessKey, "AccessKey"); + + // AuthType from configurationInfo already passed ValidateServerConfigurationParams, but an + // AKEYLESS_AUTH_TYPE override bypasses that check entirely, so re-validate the resolved value + // here — otherwise an unrecognised override silently falls through to "no authentication + // performed" below instead of failing fast. + if (!AkeylessConfiguration.SupportedAuthMethods.Contains(authType)) + { + Logger.LogError( + "Unsupported auth type '{AuthType}' resolved for Akeyless client initialization", authType); + throw new InvalidClientConfigurationException( + $"Invalid auth type '{authType}' specified. Supported auth types are: [{string.Join(", ", AkeylessConfiguration.SupportedAuthMethods)}]."); + } var client = _clientFactory(basePath); - switch (configurationInfo.AuthType) - { - case "access_key": - Logger.LogDebug("Authenticating with Akeyless using access_key auth, AccessId: '{AccessId}'", - configurationInfo.AccessId); - var token = client.Authenticate(configurationInfo.AccessId, configurationInfo.AccessKey); - - if (string.IsNullOrEmpty(token)) - { - Logger.LogError( - "Authentication failed: unable to obtain access token from Akeyless for AccessId '{AccessId}'", - configurationInfo.AccessId); - throw new InvalidTokenException("Unable to obtain access token from Akeyless server"); - } - - AuthToken = token; - Logger.LogInformation( - "Successfully authenticated with Akeyless using AccessId '{AccessId}'", - configurationInfo.AccessId); - break; + // authType is provably "access_key" here — the SupportedAuthMethods guard clause above + // already rejected anything else, and that's currently the only supported value. + Logger.LogDebug("Authenticating with Akeyless using access_key auth, AccessId: '{AccessId}'", + accessId); + var token = client.Authenticate(accessId, accessKey); - default: - Logger.LogWarning( - "No authentication performed for unrecognised auth type '{AuthType}'", - configurationInfo.AuthType); - break; + if (string.IsNullOrEmpty(token)) + { + Logger.LogError( + "Authentication failed: unable to obtain access token from Akeyless for AccessId '{AccessId}'", + accessId); + throw new InvalidTokenException("Unable to obtain access token from Akeyless server"); } + AuthToken = token; + Logger.LogInformation( + "Successfully authenticated with Akeyless using AccessId '{AccessId}'", + accessId); + return client; } catch (ApiException ex) { - // NOTE: ex.Message is intentionally excluded — ApiException error content may echo back - // portions of the auth request body, including credentials. - Logger.LogError(ex, "Akeyless API exception during authentication (HTTP {StatusCode})", ex.ErrorCode); + // NOTE: the exception object itself (not just ex.Message) is intentionally excluded from + // the log call — ApiException error content may echo back portions of the auth request + // body, including credentials, and most ILogger providers render an attached exception's + // Message/ToString() regardless of the message template, so passing `ex` here would defeat + // that exclusion. + Logger.LogError( + "Akeyless API exception during authentication (HTTP {StatusCode}) for AccessId '{AccessId}'", + ex.ErrorCode, accessId); throw new InvalidClientConfigurationException( $"Unable to authenticate to Akeyless API (HTTP {ex.ErrorCode}). Check AccessId and AccessKey configuration."); } @@ -331,7 +437,6 @@ private async Task GetAkeylessSecretAsync(AkeylessConfiguration configur try { Logger.MethodEntry(); - Logger.LogDebug("Connecting to Akeyless at '{Url}'", configurationInfo.Url); var client = InitClient(configurationInfo); switch (configurationInfo.SecretType) @@ -533,7 +638,11 @@ private AkeylessConfiguration BuildAkeylessConfiguration( "Akeyless configuration is invalid, please review server logs."); } - if (!connectionConfiguration.TryGetValue(AkeylessConfiguration.AUTH_TYPE, out var authType)) + // string.IsNullOrEmpty (not just TryGetValue's presence check) so a dictionary entry explicitly + // bound to null - as well as an absent key - defaults to 'access_key' instead of crashing the + // .Trim() call below with a NullReferenceException. + if (!connectionConfiguration.TryGetValue(AkeylessConfiguration.AUTH_TYPE, out var authType) || + string.IsNullOrEmpty(authType)) { Logger.LogWarning( "'{AuthType}' parameter not provided; defaulting to 'access_key'", @@ -541,12 +650,27 @@ private AkeylessConfiguration BuildAkeylessConfiguration( authType = "access_key"; } + authType = authType.Trim(); + + // Same null-safety as above: GetValueOrDefault only substitutes the fallback for an absent key, + // not one explicitly bound to null, so the null case is handled before .Trim() rather than by it. + var configuredUrl = connectionConfiguration.GetValueOrDefault(AkeylessConfiguration.AKEYLESS_API_URL); + var config = new AkeylessConfiguration { - Url = connectionConfiguration.GetValueOrDefault(AkeylessConfiguration.AKEYLESS_API_URL, - AkeylessConstants.DefaultAkeylessApiUrl), + // Trimmed like ResolveEnvOverride's env-var values are — Command-portal/manifest.json + // values commonly carry an incidental trailing newline too, and this PR's own hardening + // must not turn that previously-benign artifact into a hard configuration failure. + Url = (string.IsNullOrWhiteSpace(configuredUrl) ? AkeylessConstants.DefaultAkeylessApiUrl : configuredUrl) + .Trim(), AuthType = authType }; + // Validated here, before the first log statement that echoes these values, rather than relying + // on InitClient's later EnsurePrintableAscii calls — those run on a separate, later call path + // (GetAkeylessSecretAsync -> InitClient) and would leave a malicious/malformed Command-configured + // value (no env var override needed) logged raw by the two LogDebug calls below. + EnsurePrintableAscii(config.Url, "Url"); + EnsurePrintableAscii(config.AuthType, "AuthType"); Logger.LogDebug("Using Akeyless URL '{Url}', auth type '{AuthType}'", config.Url, config.AuthType); switch (authType) @@ -555,8 +679,10 @@ private AkeylessConfiguration BuildAkeylessConfiguration( Logger.LogDebug("Implicit auth type configured; credentials expected via environment variables"); break; case "access_key": - config.AccessId = connectionConfiguration[AkeylessConfiguration.ACCESS_ID]; - config.AccessKey = connectionConfiguration[AkeylessConfiguration.ACCESS_KEY]; + config.AccessId = connectionConfiguration[AkeylessConfiguration.ACCESS_ID].Trim(); + config.AccessKey = connectionConfiguration[AkeylessConfiguration.ACCESS_KEY].Trim(); + EnsurePrintableAscii(config.AccessId, "AccessId"); + EnsurePrintableAscii(config.AccessKey, "AccessKey"); // NOTE: AccessId logged (not secret), AccessKey intentionally omitted. Logger.LogDebug("Access key auth configured with AccessId '{AccessId}'", config.AccessId); break; diff --git a/docs/akeyless.md b/docs/akeyless.md index 4b15fca..1a66bb5 100644 --- a/docs/akeyless.md +++ b/docs/akeyless.md @@ -7,6 +7,7 @@ these authentication methods, see the [Akeyless documentation](https://docs.akey ## Requirements - Akeyless credentials w/ permission to access the secret(s) being used. See the [Akeyless documentation](https://docs.akeyless.io/reference/auth) for more information on how to configure the different types of auth. +- (Optional) `AKEYLESS_API_URL`, `AKEYLESS_AUTH_TYPE`, `AKEYLESS_ACCESS_ID`, and `AKEYLESS_ACCESS_KEY` environment variables can be set on the provider's host process to override the corresponding `manifest.json`/Command portal parameters at runtime. See [Configuration](docs/akeyless.md#configuration) for details and precedence. ## Mechanics @@ -17,6 +18,17 @@ docs [here](https://docs.akeyless.io/docs/access-and-authentication-methods). Once API access is configured the credential *MUST* be granted access to view secret(s) you'll be using. +### Akeyless API Endpoints Used + +The provider calls exactly two Akeyless REST API endpoints, both against the configured base URL (default `https://api.akeyless.io`, see the `Url` initialization parameter / `AKEYLESS_API_URL` environment variable above): + +| Endpoint | Method | Called from | Purpose | +|---|---|---|---| +| [`/auth`](https://docs.akeyless.io/reference/auth) | `POST` | `AkeylessApiClient.Authenticate` (invoked once per `GetPassword` call, before secret retrieval) | Exchanges the configured `AccessId`/`AccessKey` for a short-lived auth token. | +| [`/get-secret-value`](https://docs.akeyless.io/reference/getsecretvalue) | `POST` | `AkeylessApiClient.GetSecretValuesAsync` (invoked once per `GetPassword` call, after authentication) | Retrieves the value of the secret named by the `SecretName` instance parameter, using the token from `/auth`. | + +No other Akeyless API endpoints are called by this provider — it only ever authenticates and reads a single static secret value per credential lookup. It never creates, updates, deletes, or lists items in Akeyless. + ### Granting an Auth Method Access to a Secret In Akeyless, access is controlled through **Access Roles**. A role ties one or more auth methods to a set of permitted item paths. The steps below show how to grant an API Key auth method read access to a secret using the Akeyless console. @@ -116,6 +128,25 @@ Below you will find a list of supported [auth methods](#supported-authentication these authentication methods, see the [Akeyless documentation](https://docs.akeyless.io/reference/auth) - Akeyless credentials w/ permission to access the secret(s) being used. See the [Akeyless documentation](https://docs.akeyless.io/reference/auth) for more information on how to configure the different types of auth. +- (Optional) `AKEYLESS_API_URL`, `AKEYLESS_AUTH_TYPE`, `AKEYLESS_ACCESS_ID`, and `AKEYLESS_ACCESS_KEY` environment variables can be set on the provider's host process to override the corresponding `manifest.json`/Command portal parameters at runtime. See [Configuration](docs/akeyless.md#configuration) for details and precedence. + +## Configuration + +Connection and authentication parameters can be set in two ways: + +1. **`manifest.json`/Command portal parameters** — set via the `manifest.json` `InitializationInfo` block (Universal Orchestrator installs) or the corresponding fields in the Command portal PAM provider configuration (Command host installs). This is the standard way to configure the provider. +2. **Environment variables** — if set on the host process running the PAM provider (the Keyfactor Command server for local installs, or the Universal Orchestrator host for remote installs), these override whatever value is configured via `manifest.json` or the Command portal. This is useful when connection details need to be controlled at the infrastructure/deployment level rather than baked into provider configuration — for example, pointing different environments (dev/stage/prod) at different Akeyless instances or credentials without changing `manifest.json` or Command PAM provider settings. + +| Environment Variable | Overrides | Falls Back To | +|---|---|---| +| `AKEYLESS_API_URL` | `Url` | configured `Url` initialization parameter, then default (`https://api.akeyless.io`) | +| `AKEYLESS_AUTH_TYPE` | `AuthType` | configured `AuthType` initialization parameter | +| `AKEYLESS_ACCESS_ID` | `AccessId` | configured `AccessId` initialization parameter | +| `AKEYLESS_ACCESS_KEY` | `AccessKey` | configured `AccessKey` initialization parameter | + +Precedence for each: environment variable (if set to a non-empty, non-whitespace-only value) > configured initialization parameter > default (`Url` only). An environment variable that is unset, or explicitly set to an empty or whitespace-only string, is treated as "not overriding" and falls through to the configured value. An override value is trimmed of leading/trailing whitespace before use (a trailing newline is a common artifact of file-mounted/`envFrom` secret provisioning). The effective value of `Url`, `AuthType`, `AccessId`, and `AccessKey` — whether it came from an override or from Command's/`manifest.json`'s configuration — must be printable ASCII; any other character (an embedded newline, ANSI escape sequence, Unicode line separator, or a bidirectional-override/zero-width character such as U+202E) is rejected with an error rather than used as-is. When an override is active, the provider logs a **Warning** stating which environment variable is overriding (never the value), so an incident investigation can confirm whether the effective connection parameter matches Command's recorded configuration. An `AKEYLESS_AUTH_TYPE` override that does not match a supported auth type fails the request immediately rather than silently skipping authentication. + +**Upgrading an existing installation:** if the provider's host process already has `AKEYLESS_AUTH_TYPE`, `AKEYLESS_ACCESS_ID`, or `AKEYLESS_ACCESS_KEY` set for an unrelated reason (for example, a co-located Akeyless CLI or another Akeyless SDK conventionally uses these same variable names), upgrading to a version of this provider that reads them will silently start authenticating with that ambient identity instead of the one recorded in `manifest.json`/Command — with no configuration change on Command's side. Check the host environment for these variable names before upgrading, and watch for the Warning-level "Environment variable override active" log line afterward. ## Supported Authentication Methods @@ -229,6 +260,17 @@ docs [here](https://docs.akeyless.io/docs/access-and-authentication-methods). Once API access is configured the credential *MUST* be granted access to view secret(s) you'll be using. +### Akeyless API Endpoints Used + +The provider calls exactly two Akeyless REST API endpoints, both against the configured base URL (default `https://api.akeyless.io`, see the `Url` initialization parameter / `AKEYLESS_API_URL` environment variable above): + +| Endpoint | Method | Called from | Purpose | +|---|---|---|---| +| [`/auth`](https://docs.akeyless.io/reference/auth) | `POST` | `AkeylessApiClient.Authenticate` (invoked once per `GetPassword` call, before secret retrieval) | Exchanges the configured `AccessId`/`AccessKey` for a short-lived auth token. | +| [`/get-secret-value`](https://docs.akeyless.io/reference/getsecretvalue) | `POST` | `AkeylessApiClient.GetSecretValuesAsync` (invoked once per `GetPassword` call, after authentication) | Retrieves the value of the secret named by the `SecretName` instance parameter, using the token from `/auth`. | + +No other Akeyless API endpoints are called by this provider — it only ever authenticates and reads a single static secret value per credential lookup. It never creates, updates, deletes, or lists items in Akeyless. + ### Granting an Auth Method Access to a Secret In Akeyless, access is controlled through **Access Roles**. A role ties one or more auth methods to a set of permitted item paths. The steps below show how to grant an API Key auth method read access to a secret using the Akeyless console. diff --git a/docsource/akeyless.md b/docsource/akeyless.md index 0f79681..aeb8154 100644 --- a/docsource/akeyless.md +++ b/docsource/akeyless.md @@ -7,6 +7,25 @@ these authentication methods, see the [Akeyless documentation](https://docs.akey ## Requirements - Akeyless credentials w/ permission to access the secret(s) being used. See the [Akeyless documentation](https://docs.akeyless.io/reference/auth) for more information on how to configure the different types of auth. +- (Optional) `AKEYLESS_API_URL`, `AKEYLESS_AUTH_TYPE`, `AKEYLESS_ACCESS_ID`, and `AKEYLESS_ACCESS_KEY` environment variables can be set on the provider's host process to override the corresponding `manifest.json`/Command portal parameters at runtime. See [Configuration](docs/akeyless.md#configuration) for details and precedence. + +## Configuration + +Connection and authentication parameters can be set in two ways: + +1. **`manifest.json`/Command portal parameters** — set via the `manifest.json` `InitializationInfo` block (Universal Orchestrator installs) or the corresponding fields in the Command portal PAM provider configuration (Command host installs). This is the standard way to configure the provider. +2. **Environment variables** — if set on the host process running the PAM provider (the Keyfactor Command server for local installs, or the Universal Orchestrator host for remote installs), these override whatever value is configured via `manifest.json` or the Command portal. This is useful when connection details need to be controlled at the infrastructure/deployment level rather than baked into provider configuration — for example, pointing different environments (dev/stage/prod) at different Akeyless instances or credentials without changing `manifest.json` or Command PAM provider settings. + +| Environment Variable | Overrides | Falls Back To | +|---|---|---| +| `AKEYLESS_API_URL` | `Url` | configured `Url` initialization parameter, then default (`https://api.akeyless.io`) | +| `AKEYLESS_AUTH_TYPE` | `AuthType` | configured `AuthType` initialization parameter | +| `AKEYLESS_ACCESS_ID` | `AccessId` | configured `AccessId` initialization parameter | +| `AKEYLESS_ACCESS_KEY` | `AccessKey` | configured `AccessKey` initialization parameter | + +Precedence for each: environment variable (if set to a non-empty, non-whitespace-only value) > configured initialization parameter > default (`Url` only). An environment variable that is unset, or explicitly set to an empty or whitespace-only string, is treated as "not overriding" and falls through to the configured value. An override value is trimmed of leading/trailing whitespace before use (a trailing newline is a common artifact of file-mounted/`envFrom` secret provisioning). The effective value of `Url`, `AuthType`, `AccessId`, and `AccessKey` — whether it came from an override or from Command's/`manifest.json`'s configuration — must be printable ASCII; any other character (an embedded newline, ANSI escape sequence, Unicode line separator, or a bidirectional-override/zero-width character such as U+202E) is rejected with an error rather than used as-is. When an override is active, the provider logs a **Warning** stating which environment variable is overriding (never the value), so an incident investigation can confirm whether the effective connection parameter matches Command's recorded configuration. An `AKEYLESS_AUTH_TYPE` override that does not match a supported auth type fails the request immediately rather than silently skipping authentication. + +**Upgrading an existing installation:** if the provider's host process already has `AKEYLESS_AUTH_TYPE`, `AKEYLESS_ACCESS_ID`, or `AKEYLESS_ACCESS_KEY` set for an unrelated reason (for example, a co-located Akeyless CLI or another Akeyless SDK conventionally uses these same variable names), upgrading to a version of this provider that reads them will silently start authenticating with that ambient identity instead of the one recorded in `manifest.json`/Command — with no configuration change on Command's side. Check the host environment for these variable names before upgrading, and watch for the Warning-level "Environment variable override active" log line afterward. ## Supported Authentication Methods @@ -123,6 +142,17 @@ docs [here](https://docs.akeyless.io/docs/access-and-authentication-methods). Once API access is configured the credential *MUST* be granted access to view secret(s) you'll be using. +### Akeyless API Endpoints Used + +The provider calls exactly two Akeyless REST API endpoints, both against the configured base URL (default `https://api.akeyless.io`, see the `Url` initialization parameter / `AKEYLESS_API_URL` environment variable above): + +| Endpoint | Method | Called from | Purpose | +|---|---|---|---| +| [`/auth`](https://docs.akeyless.io/reference/auth) | `POST` | `AkeylessApiClient.Authenticate` (invoked once per `GetPassword` call, before secret retrieval) | Exchanges the configured `AccessId`/`AccessKey` for a short-lived auth token. | +| [`/get-secret-value`](https://docs.akeyless.io/reference/getsecretvalue) | `POST` | `AkeylessApiClient.GetSecretValuesAsync` (invoked once per `GetPassword` call, after authentication) | Retrieves the value of the secret named by the `SecretName` instance parameter, using the token from `/auth`. | + +No other Akeyless API endpoints are called by this provider — it only ever authenticates and reads a single static secret value per credential lookup. It never creates, updates, deletes, or lists items in Akeyless. + ### Granting an Auth Method Access to a Secret In Akeyless, access is controlled through **Access Roles**. A role ties one or more auth methods to a set of permitted item paths. The steps below show how to grant an API Key auth method read access to a secret using the Akeyless console. diff --git a/tests/AkeylessPam.Integration.Tests/AkeylessPam.Integration.Tests.csproj b/tests/AkeylessPam.Integration.Tests/AkeylessPam.Integration.Tests.csproj index bc95379..cbe1d82 100644 --- a/tests/AkeylessPam.Integration.Tests/AkeylessPam.Integration.Tests.csproj +++ b/tests/AkeylessPam.Integration.Tests/AkeylessPam.Integration.Tests.csproj @@ -25,4 +25,8 @@ + + + + diff --git a/tests/AkeylessPam.Integration.Tests/AkeylessPamIntegrationTests.cs b/tests/AkeylessPam.Integration.Tests/AkeylessPamIntegrationTests.cs index bad91ab..6cee4b1 100644 --- a/tests/AkeylessPam.Integration.Tests/AkeylessPamIntegrationTests.cs +++ b/tests/AkeylessPam.Integration.Tests/AkeylessPamIntegrationTests.cs @@ -199,6 +199,12 @@ public void GetPassword_BadCredentials_ThrowsInvalidClientConfigurationException ["SecretName"] = Env("AKEYLESS_SECRET_STATIC_TEXT") }; + // AKEYLESS_ACCESS_ID/AKEYLESS_ACCESS_KEY (required for every other test in this suite to run) + // would otherwise override the bad credentials above via AkeylessPam's env var override support, + // making auth succeed instead of failing. Clear them for the duration of this test only. + using var idScope = new EnvVarScope("AKEYLESS_ACCESS_ID", null); + using var keyScope = new EnvVarScope("AKEYLESS_ACCESS_KEY", null); + var pam = new AkeylessPam(); var ex = Assert.Throws(() => pam.GetPassword(instance, server)); Assert.IsType(ex.InnerException); diff --git a/tests/AkeylessPam.Integration.Tests/AssemblyInfo.cs b/tests/AkeylessPam.Integration.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..9e3253d --- /dev/null +++ b/tests/AkeylessPam.Integration.Tests/AssemblyInfo.cs @@ -0,0 +1,15 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using Xunit; + +// GetPassword_BadCredentials_ThrowsInvalidClientConfigurationException temporarily clears the process-wide +// AKEYLESS_ACCESS_ID/AKEYLESS_ACCESS_KEY environment variables so the bad credentials it passes via server +// config parameters aren't overridden by AkeylessPam's env var override support. Test classes run in +// parallel by default in xUnit, which could race with AkeylessApiClientTests reading those same env vars. +// Disabling parallelization keeps env var state deterministic across the whole assembly. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/AkeylessPam.Integration.Tests/README.md b/tests/AkeylessPam.Integration.Tests/README.md index c4ace5d..48f6418 100644 --- a/tests/AkeylessPam.Integration.Tests/README.md +++ b/tests/AkeylessPam.Integration.Tests/README.md @@ -42,7 +42,7 @@ End-to-end tests for `AkeylessPam.GetPassword()` against a live Akeyless instanc | `GetPassword_StaticJson_UsernameField_ReturnsValue` | credentials + `AKEYLESS_SECRET_STATIC_JSON` | Retrieves the `username` field from a `static_json` secret | | `GetPassword_StaticJson_PasswordField_ReturnsValue` | credentials + `AKEYLESS_SECRET_STATIC_JSON` | Retrieves the `password` field from a `static_json` secret | | `GetPassword_StaticJson_NoFieldName_ReturnsRawJsonBlob` | credentials + `AKEYLESS_SECRET_STATIC_JSON_RAW` | Retrieves a `static_json` secret without specifying a field, asserts result is a JSON object or array | -| `GetPassword_BadCredentials_ThrowsInvalidClientConfigurationException` | `AKEYLESS_SECRET_STATIC_TEXT` (no credentials needed) | Intentionally uses invalid credentials and asserts `InvalidClientConfigurationException` is thrown | +| `GetPassword_BadCredentials_ThrowsInvalidClientConfigurationException` | `AKEYLESS_SECRET_STATIC_TEXT` | Intentionally uses invalid credentials and asserts `InvalidClientConfigurationException` is thrown. Clears `AKEYLESS_ACCESS_ID`/`AKEYLESS_ACCESS_KEY` for the duration of the test so the env var override feature doesn't replace the bad credentials with real ones | | `GetPassword_NonexistentSecret_ThrowsException` | credentials | Requests a secret path that does not exist and asserts an exception is thrown | | `GetPassword_StaticJson_NoFieldName_ReturnsRawJsonBlob_K8sOrchestratorSecret` | credentials | Retrieves `/pam/test/k8s-orchestrator` as `static_json` with no field name and asserts the full JSON blob is returned | | `GetPassword_StaticJson_WhitespaceFieldName_ReturnsRawJsonBlob_K8sOrchestratorSecret` | credentials | Same as above but passes a whitespace-only `StaticSecretFieldName` (simulating the Keyfactor Command portal behavior); asserts the full JSON blob is returned | @@ -62,3 +62,4 @@ Lower-level tests for `AkeylessApiClient` — the adapter that wraps the Akeyles | `GetSecretValuesAsync_StaticJsonSecret_ReturnsDictWithValue` | Retrieves a `static_json` secret and asserts the response dictionary contains a non-empty value | | `GetSecretValuesAsync_MultipleSecrets_ReturnsAllRequested` | Requests two secrets in a single API call and asserts both keys are present in the response | | `GetSecretValuesAsync_InvalidToken_ThrowsApiException` | Calls `GetSecretValuesAsync` with an invalid token and asserts an `ApiException` is thrown | +| `Debug_K8sOrchestratorSecret_PrintsRawValue` | Retrieves the hardcoded `/pam/test/k8s-orchestrator` secret directly via the API client and asserts the response contains that key (the value itself is intentionally not written to output, to avoid secret exposure in CI logs) | diff --git a/tests/AkeylessPam.Unit.Tests/AkeylessPamTests.cs b/tests/AkeylessPam.Unit.Tests/AkeylessPamTests.cs index 9baf37b..58da6be 100644 --- a/tests/AkeylessPam.Unit.Tests/AkeylessPamTests.cs +++ b/tests/AkeylessPam.Unit.Tests/AkeylessPamTests.cs @@ -5,6 +5,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. +using akeyless.Client; using Keyfactor.Extensions.Pam.Akeyless; using Moq; using Xunit; @@ -128,6 +129,21 @@ public void GetPassword_AuthenticateReturnsEmptyToken_ThrowsInvalidTokenExceptio Assert.IsType(ex.InnerException); } + [Fact] + public void GetPassword_AuthenticateThrowsApiException_WrapsAsInvalidClientConfigurationException() + { + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Throws(new ApiException(401, "Unauthorized")); + + var pam = new AkeylessPam(_ => mock.Object); + + var ex = Assert.Throws(() => + pam.GetPassword(Params.Instance(), Params.ValidServer())); + + Assert.IsType(ex.InnerException); + } + [Fact] public void GetPassword_UsesConfiguredUrl_WhenNoEnvVar() { @@ -150,6 +166,431 @@ public void GetPassword_UsesConfiguredUrl_WhenNoEnvVar() } } +public class ConfiguredValueSanitizationTests +{ + // Mirrors the env-var-override trimming tests: Command-portal/manifest.json-configured values + // commonly carry an incidental trailing newline too (e.g. from hand-editing JSON or a paste with a + // stray line terminator), and BuildAkeylessConfiguration must trim it the same way + // ResolveEnvOverride trims env-var-sourced values, rather than hard-failing on a previously-benign + // artifact. + + [Fact] + public void GetPassword_ConfiguredUrlHasTrailingNewline_IsTrimmedBeforeUse() + { + // Cleared, not just left alone, so this test is hermetic against a real AKEYLESS_API_URL + // already present in the ambient environment (e.g. a developer's shell). + using var _ = new EnvVarScope("AKEYLESS_API_URL", null); + + string? capturedBasePath = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(basePath => + { + capturedBasePath = basePath; + return mock.Object; + }); + + pam.GetPassword(Params.Instance(), Params.ValidServer(url: "https://api.akeyless.io\n")); + + Assert.Equal("https://api.akeyless.io", capturedBasePath); + } + + [Fact] + public void GetPassword_ConfiguredAuthTypeHasTrailingNewline_IsTrimmedBeforeUse() + { + using var _ = new EnvVarScope("AKEYLESS_AUTH_TYPE", null); + + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + pam.GetPassword(Params.Instance(), Params.ValidServer(authType: "access_key\n")); + + mock.Verify(c => c.Authenticate(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void GetPassword_ConfiguredAccessIdHasTrailingNewline_IsTrimmedBeforeUse() + { + using var _ = new EnvVarScope("AKEYLESS_ACCESS_ID", null); + + string? capturedAccessId = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Callback((accessId, _) => capturedAccessId = accessId) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + pam.GetPassword(Params.Instance(), Params.ValidServer(accessId: "configured-access-id\n")); + + Assert.Equal("configured-access-id", capturedAccessId); + } + + [Fact] + public void GetPassword_ConfiguredAccessKeyHasTrailingNewline_IsTrimmedBeforeUse() + { + using var _ = new EnvVarScope("AKEYLESS_ACCESS_KEY", null); + + string? capturedAccessKey = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Callback((_, accessKey) => capturedAccessKey = accessKey) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + pam.GetPassword(Params.Instance(), Params.ValidServer(accessKey: "configured-access-key\n")); + + Assert.Equal("configured-access-key", capturedAccessKey); + } + + [Fact] + public void GetPassword_ConfiguredAuthTypeKeyPresentButNull_DefaultsToAccessKeyInsteadOfThrowingNullReferenceException() + { + using var _ = new EnvVarScope("AKEYLESS_AUTH_TYPE", null); + + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + var server = Params.ValidServer(); + server["AuthType"] = null!; // key present, value null — legal for Dictionary + + pam.GetPassword(Params.Instance(), server); + + mock.Verify(c => c.Authenticate(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void GetPassword_ConfiguredUrlKeyPresentButNull_FallsBackToDefaultInsteadOfThrowingNullReferenceException() + { + using var _ = new EnvVarScope("AKEYLESS_API_URL", null); + + string? capturedBasePath = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(basePath => + { + capturedBasePath = basePath; + return mock.Object; + }); + var server = Params.ValidServer(); + server["Url"] = null!; // key present, value null — legal for Dictionary + + pam.GetPassword(Params.Instance(), server); + + Assert.Equal("https://api.akeyless.io", capturedBasePath); + } + + [Fact] + public void GetPassword_ConfiguredUrlIsWhitespaceOnly_FallsBackToDefaultInsteadOfEmptyBasePath() + { + using var _ = new EnvVarScope("AKEYLESS_API_URL", null); + + string? capturedBasePath = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(basePath => + { + capturedBasePath = basePath; + return mock.Object; + }); + + pam.GetPassword(Params.Instance(), Params.ValidServer(url: " ")); + + Assert.Equal("https://api.akeyless.io", capturedBasePath); + } +} + +public class EnvironmentVariableOverrideTests +{ + [Fact] + public void GetPassword_AkeylessApiUrlEnvVar_OverridesConfiguredUrl() + { + using var _ = new EnvVarScope("AKEYLESS_API_URL", "https://env-override.akeyless.io"); + + string? capturedBasePath = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())).Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(basePath => + { + capturedBasePath = basePath; + return mock.Object; + }); + + pam.GetPassword(Params.Instance(), Params.ValidServer(url: "https://configured.akeyless.io")); + + Assert.Equal("https://env-override.akeyless.io", capturedBasePath); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetPassword_AkeylessApiUrlEnvVarUnsetOrBlank_FallsBackToConfiguredUrl(string? envValue) + { + using var _ = new EnvVarScope("AKEYLESS_API_URL", envValue); + + string? capturedBasePath = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())).Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(basePath => + { + capturedBasePath = basePath; + return mock.Object; + }); + + pam.GetPassword(Params.Instance(), Params.ValidServer(url: "https://configured.akeyless.io")); + + Assert.Equal("https://configured.akeyless.io", capturedBasePath); + } + + [Fact] + public void GetPassword_AkeylessAccessIdEnvVar_OverridesConfiguredAccessId() + { + using var _ = new EnvVarScope("AKEYLESS_ACCESS_ID", "env-access-id"); + + string? capturedAccessId = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Callback((accessId, _) => capturedAccessId = accessId) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + pam.GetPassword(Params.Instance(), Params.ValidServer(accessId: "configured-access-id")); + + Assert.Equal("env-access-id", capturedAccessId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetPassword_AkeylessAccessIdEnvVarUnsetOrBlank_FallsBackToConfiguredAccessId(string? envValue) + { + using var _ = new EnvVarScope("AKEYLESS_ACCESS_ID", envValue); + + string? capturedAccessId = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Callback((accessId, _) => capturedAccessId = accessId) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + pam.GetPassword(Params.Instance(), Params.ValidServer(accessId: "configured-access-id")); + + Assert.Equal("configured-access-id", capturedAccessId); + } + + [Fact] + public void GetPassword_AkeylessAccessIdEnvVarHasTrailingNewline_IsTrimmedBeforeUse() + { + // A trailing newline is a common artifact of file-mounted/envFrom secret provisioning; it must not + // be sent to Akeyless as part of the credential. + using var _ = new EnvVarScope("AKEYLESS_ACCESS_ID", "env-access-id\n"); + + string? capturedAccessId = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Callback((accessId, _) => capturedAccessId = accessId) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + pam.GetPassword(Params.Instance(), Params.ValidServer(accessId: "configured-access-id")); + + Assert.Equal("env-access-id", capturedAccessId); + } + + [Theory] + [InlineData("env-access-id\nforged log line")] // embedded LF, survives Trim() + [InlineData("env-access-id\u2028forged log line")] // Unicode LINE SEPARATOR + [InlineData("env-access-id\u2029forged log line")] // Unicode PARAGRAPH SEPARATOR + [InlineData("env-access-idtampered")] // ANSI escape sequence (ESC is a control character) + [InlineData("env-access-id\u202Ehidden-suffix")] // Unicode RIGHT-TO-LEFT OVERRIDE (Format category) + [InlineData("env-access-id\uFE0Ftampered")] // Unicode Variation Selector (Nonspacing-Mark category) + public void GetPassword_AkeylessAccessIdEnvVarHasEmbeddedLineBreakOrControlCharacter_ThrowsInvalidClientConfigurationException( + string maliciousValue) + { + // Non-ASCII line-terminating separators, ANSI escape sequences, bidi-override characters, and + // zero-width variation selectors can all equally forge or tamper with rendered log output. Rather + // than blocklisting each character class as it's discovered, the printable-ASCII allowlist rejects + // all of them (and anything else non-ASCII) in one check. + using var _ = new EnvVarScope("AKEYLESS_ACCESS_ID", maliciousValue); + + var mock = new Mock(); + var pam = new AkeylessPam(_ => mock.Object); + + var ex = Assert.Throws(() => + pam.GetPassword(Params.Instance(), Params.ValidServer(accessId: "configured-access-id"))); + + Assert.IsType(ex.InnerException); + mock.Verify(c => c.Authenticate(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void GetPassword_ConfiguredAccessIdHasEmbeddedControlCharacter_ThrowsInvalidClientConfigurationException() + { + // The printable-ASCII allowlist is applied to the configured value in BuildAkeylessConfiguration + // (before its own log statements echo it), not just to the final resolved value in InitClient, so + // a malicious/malformed AccessId supplied via Command's server configuration (no env var override + // involved) is rejected too -- not just the override path. Explicitly unset the env var so an + // ambient AKEYLESS_ACCESS_ID on the host running the tests can't silently override it. + using var _ = new EnvVarScope("AKEYLESS_ACCESS_ID", null); + + var mock = new Mock(); + var pam = new AkeylessPam(_ => mock.Object); + + // BuildAkeylessConfiguration runs synchronously (before the .Result call boundary), so this throws + // directly rather than wrapped in an AggregateException. + Assert.Throws(() => + pam.GetPassword(Params.Instance(), Params.ValidServer(accessId: "configured-id\r\nforged log line"))); + + mock.Verify(c => c.Authenticate(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void GetPassword_ConfiguredUrlHasEmbeddedControlCharacter_ThrowsInvalidClientConfigurationException() + { + // Same as above but for Url: BuildAkeylessConfiguration's own "Using Akeyless URL ..." debug log + // echoes config.Url before InitClient is ever reached, so this must be validated at that point too. + using var _ = new EnvVarScope("AKEYLESS_API_URL", null); + + var mock = new Mock(); + var pam = new AkeylessPam(_ => mock.Object); + + Assert.Throws(() => + pam.GetPassword(Params.Instance(), Params.ValidServer(url: "https://api.akeyless.io\r\nforged log line"))); + + mock.Verify(c => c.Authenticate(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void GetPassword_AkeylessAccessKeyEnvVar_OverridesConfiguredAccessKey() + { + using var _ = new EnvVarScope("AKEYLESS_ACCESS_KEY", "env-access-key"); + + string? capturedAccessKey = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Callback((_, accessKey) => capturedAccessKey = accessKey) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + pam.GetPassword(Params.Instance(), Params.ValidServer(accessKey: "configured-access-key")); + + Assert.Equal("env-access-key", capturedAccessKey); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetPassword_AkeylessAccessKeyEnvVarUnsetOrBlank_FallsBackToConfiguredAccessKey(string? envValue) + { + using var _ = new EnvVarScope("AKEYLESS_ACCESS_KEY", envValue); + + string? capturedAccessKey = null; + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Callback((_, accessKey) => capturedAccessKey = accessKey) + .Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + pam.GetPassword(Params.Instance(), Params.ValidServer(accessKey: "configured-access-key")); + + Assert.Equal("configured-access-key", capturedAccessKey); + } + + [Fact] + public void GetPassword_AkeylessAuthTypeEnvVarUnrecognised_ThrowsInvalidClientConfigurationException() + { + // Override the configured "access_key" auth type with an unrecognised value via env var — this must + // fail fast rather than silently skipping authentication and proceeding with an unauthenticated request. + using var _ = new EnvVarScope("AKEYLESS_AUTH_TYPE", "unrecognised_env_auth_type"); + + var mock = new Mock(); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + var ex = Assert.Throws(() => + pam.GetPassword(Params.Instance(), Params.ValidServer(authType: "access_key"))); + + Assert.IsType(ex.InnerException); + mock.Verify(c => c.Authenticate(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + // A trailing newline (e.g. from file-mounted/envFrom secret provisioning) must not cause the + // resolved auth type to fail the "access_key" match and fall into the unsupported-auth-type path. + [InlineData("access_key\n")] + public void GetPassword_AkeylessAuthTypeEnvVarUnsetOrBlankOrHasTrailingNewline_FallsBackToOrTrims( + string? envValue) + { + using var _ = new EnvVarScope("AKEYLESS_AUTH_TYPE", envValue); + + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())).Returns("fake-token"); + mock.Setup(c => c.GetSecretValuesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { ["pam/test/secret"] = "value" }); + + var pam = new AkeylessPam(_ => mock.Object); + + pam.GetPassword(Params.Instance(), Params.ValidServer(authType: "access_key")); + + mock.Verify(c => c.Authenticate(It.IsAny(), It.IsAny()), Times.Once); + } +} + public class SecretRetrievalTests { private static AkeylessPam PamWithMockReturning(string secretName, string secretValue) diff --git a/tests/AkeylessPam.Unit.Tests/AssemblyInfo.cs b/tests/AkeylessPam.Unit.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..ae8fc57 --- /dev/null +++ b/tests/AkeylessPam.Unit.Tests/AssemblyInfo.cs @@ -0,0 +1,15 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using Xunit; + +// Environment variable override tests (EnvironmentVariableOverrideTests) mutate process-wide environment +// variables (AKEYLESS_API_URL, AKEYLESS_AUTH_TYPE, AKEYLESS_ACCESS_ID, AKEYLESS_ACCESS_KEY). Test classes run +// in parallel by default in xUnit, which could race with other tests that assume these env vars are unset +// (e.g. GetPassword_UsesConfiguredUrl_WhenNoEnvVar). Disabling parallelization keeps env var state +// deterministic across the whole assembly. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/AkeylessPam.Unit.Tests/EnvVarScope.cs b/tests/AkeylessPam.Unit.Tests/EnvVarScope.cs new file mode 100644 index 0000000..1ade9e4 --- /dev/null +++ b/tests/AkeylessPam.Unit.Tests/EnvVarScope.cs @@ -0,0 +1,22 @@ +/// +/// Sets an environment variable for the duration of a test and restores the prior value (or clears it, +/// if it was previously unset) on dispose. Ensures env var overrides used to test one PAM instance +/// don't leak into other tests or other test runs. +/// +internal sealed class EnvVarScope : IDisposable +{ + private readonly string _name; + private readonly string? _previousValue; + + public EnvVarScope(string name, string? value) + { + _name = name; + _previousValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(_name, _previousValue); + } +}