diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5a35c3d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* linguist-language=C# \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..afc9fae --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,62 @@ +name: Tests + +on: + push: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Authenticate to Keyfactor NuGet feed + run: dotnet nuget add source https://nuget.pkg.github.com/Keyfactor/index.json -n keyfactor -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text + + - name: Restore + run: dotnet restore tests/AkeylessPam.Unit.Tests/AkeylessPam.Unit.Tests.csproj + + - name: Run unit tests + run: dotnet test tests/AkeylessPam.Unit.Tests/ --no-restore --collect:"XPlat Code Coverage" --results-directory ./coverage + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-unit + path: coverage/**/coverage.cobertura.xml + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Authenticate to Keyfactor NuGet feed + run: dotnet nuget add source https://nuget.pkg.github.com/Keyfactor/index.json -n keyfactor -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text + + - name: Restore + run: dotnet restore tests/AkeylessPam.Integration.Tests/AkeylessPam.Integration.Tests.csproj + + - name: Run integration tests + env: + AKEYLESS_ACCESS_ID: ${{ secrets.AKEYLESS_ACCESS_ID }} + AKEYLESS_ACCESS_KEY: ${{ secrets.AKEYLESS_ACCESS_KEY }} + AKEYLESS_API_URL: ${{ vars.AKEYLESS_API_URL }} + AKEYLESS_SECRET_STATIC_TEXT: ${{ vars.AKEYLESS_SECRET_STATIC_TEXT }} + AKEYLESS_SECRET_STATIC_TEXT_2: ${{ vars.AKEYLESS_SECRET_STATIC_TEXT_2 }} + AKEYLESS_SECRET_STATIC_KV: ${{ vars.AKEYLESS_SECRET_STATIC_KV }} + AKEYLESS_SECRET_STATIC_JSON: ${{ vars.AKEYLESS_SECRET_STATIC_JSON }} + AKEYLESS_SECRET_STATIC_JSON_RAW: ${{ vars.AKEYLESS_SECRET_STATIC_JSON_RAW }} + run: dotnet test tests/AkeylessPam.Integration.Tests/ --no-restore diff --git a/.gitignore b/.gitignore index 649503b..91a3277 100644 --- a/.gitignore +++ b/.gitignore @@ -351,4 +351,9 @@ MigrationBackup/ *.env -.idea/* \ No newline at end of file +.idea/* + +manifest.json + +.claude/* +CLAUDE.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cdd04a1..43cab5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ # v1.0.0 -- Initial release of Akeyless PAM Provider \ No newline at end of file +Initial release of the Akeyless PAM Provider for Keyfactor Command and Universal Orchestrator. + +### Features + +- Retrieve secrets from Akeyless and surface them as credentials to Keyfactor Command certificate stores and orchestrator jobs +- **Access Key (API Key) authentication** — authenticates to the Akeyless API using an Access ID and Access Key pair +- **Static Text secrets** (`static_text`) — returns the secret value as a plain string +- **Static JSON secrets** (`static_json`) — returns the full JSON blob, or extracts a single field by name via `StaticSecretFieldName` +- **Static Key-Value secrets** (`static_kv`) — extracts a single field from a key-value secret by name via `StaticSecretFieldName` +- Configurable Akeyless API URL (defaults to `https://api.akeyless.io`); can be overridden at runtime via the `AKEYLESS_API_URL` environment variable +- Targets .NET 8.0 and .NET 10.0 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..200f45a --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +SLN := akeyless-pam.sln +LIB := akeyless-pam/akeyless-pam.csproj +UNIT := tests/AkeylessPam.Unit.Tests/AkeylessPam.Unit.Tests.csproj +INT := tests/AkeylessPam.Integration.Tests/AkeylessPam.Integration.Tests.csproj +CONSOLE := TestConsole/TestConsole.csproj +MANIFEST := akeyless-pam/manifest.json +LIB_BIN := akeyless-pam/bin + +.PHONY: all build build-release clean test test-unit test-integration console restore + +all: build + +## Build (debug) +build: + dotnet build $(LIB) + @for d in $(LIB_BIN)/Debug/net*/; do cp $(MANIFEST) $$d; done + +## Build (release) +build-release: + dotnet build $(LIB) -c Release + @for d in $(LIB_BIN)/Release/net*/; do cp $(MANIFEST) $$d; done + +## Restore NuGet packages +restore: + dotnet restore $(SLN) + +## Clean all projects +clean: + dotnet clean $(SLN) + +## Run all tests +test: + dotnet test $(SLN) + +## Run unit tests only +test-unit: + dotnet test $(UNIT) + +## Run integration tests only +test-integration: + dotnet test $(INT) + +## Run the test console +console: + dotnet run --project $(CONSOLE) + +## Show available targets +help: + @grep -E '^## ' Makefile | sed 's/## / /' + @echo "" + @echo "Targets: all build build-release restore clean test test-unit test-integration console" diff --git a/README.md b/README.md new file mode 100644 index 0000000..d971a23 --- /dev/null +++ b/README.md @@ -0,0 +1,355 @@ +

+ Akeyless PAM Provider +

+ +

+ +Integration Status: production +Release +Issues +GitHub Downloads (all assets, all releases) +

+ +

+ + + Support + + · + + Installation + + · + + License + + · + + Related Integrations + +

+ +## Overview + +The Akeyless PAM Provider allows for the retrieval of stored account credentials from an Akeyless secret. + +## Support +The Akeyless PAM Provider is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. + +> To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. + +## Getting Started + +The Akeyless PAM Provider is used by Command to resolve PAM-eligible credentials for Universal Orchestrator extensions and for accessing Certificate Authorities. When configured, Command will use the Akeyless PAM Provider to retrieve credentials needed to communicate with the target system. There are two ways to install the Akeyless PAM Provider, and you may elect to use one or both methods: + +1. **Locally on the Keyfactor Command server**: PAM credential resolution via the Akeyless PAM Provider will occur on the Keyfactor Command server each time an elegible credential is needed. +2. **Remotely On Universal Orchestrators**: When Jobs are dispatched to Universal Orchestrators, the associated Certificate Store extension assembly will use the Akeyless PAM Provider to resolve eligible PAM credentials. + +Before proceeding with installation, you should consider which pattern is best for your requirements and use case. + +### Installation + +> [!IMPORTANT] +> For the most up-to-date and complete documentation on how to install a PAM provider extension, please visit our [product documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Preparing%20Third%20Party%20PAM%20Providers%20to%20Work%20with.htm?Highlight=pam%20provider#InstallingCustomPAMProviderExtensions) + + +To install Akeyless PAM Provider, it is recommended you install [kfutil](https://github.com/Keyfactor/kfutil). `kfutil` is a command-line tool that simplifies the process of creating PAM Types in Keyfactor Command. + + + + + + + +#### 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. + +#### Create PAM type in Keyfactor Command + + +##### Using `kfutil` +Create the required PAM Types in the connected Command platform. + +```shell +# Akeyless +kfutil pam types-create -r akeyless-pam -n Akeyless +``` + +##### Using the API +For full API docs please visit our [product documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/WebAPI/KeyfactorAPI/PAMProvidersPOSTTypes.htm?Highlight=pam%20type) + +Below is the payload to `POST` to the Keyfactor Command API +```json +{ + "Name": "Akeyless", + "Parameters": [ + { + "Name": "Url", + "DisplayName": "Akeyless URL", + "Description": "The URL to the Akeyless instance. Defaults to: https://api.akeyless.io", + "DataType": 1, + "InstanceLevel": false + }, + { + "Name": "AccessId", + "DisplayName": "Access ID", + "Description": "The access key ID used to authenticate to Akeyless using `access_key` authentication.", + "DataType": 2, + "InstanceLevel": false + }, + { + "Name": "AccessKey", + "DisplayName": "Access Key", + "Description": "The access key used to authenticate to Akeyless using `access_key` authentication.", + "DataType": 2, + "InstanceLevel": false + }, + { + "Name": "AuthType", + "DisplayName": "Auth Type", + "Description": "The auth type used to authenticate to the Akeyless platform. Supported types are `access_key`.", + "DataType": 1, + "InstanceLevel": false + }, + { + "Name": "SecretName", + "DisplayName": "Secret Name", + "Description": "The full name (path) of the secret in Akeyless that contains the credential to retrieve.", + "DataType": 1, + "InstanceLevel": true + }, + { + "Name": "SecretType", + "DisplayName": "Secret Type", + "Description": "The type of secret stored in Akeyless. Supported types are `static_kv,static_text,static_json`.", + "DataType": 1, + "InstanceLevel": true + }, + { + "Name": "StaticSecretFieldName", + "DisplayName": "Static Secret Field Name", + "Description": "The field name within a static secret to retrieve the credential from. Required for `static_kv` and optional for `static_json` secret types.", + "DataType": 1, + "InstanceLevel": true + } + ] +} +``` + +#### Install PAM provider on Keyfactor Command Host (Local) + + + +1. On the server that hosts Keyfactor Command, download and unzip the latest release of the Akeyless PAM Provider from the [Releases](../../releases) page. + +2. Copy the assemblies to the appropriate directories on the Keyfactor Command server: + +
Keyfactor Command 11+ + + 1. Copy the unzipped assemblies to each of the following directories: + + * `C:\Program Files\Keyfactor\Keyfactor Platform\WebAgentServices\Extensions\akeyless-pam` + * `C:\Program Files\Keyfactor\Keyfactor Platform\WebConsole\Extensions\akeyless-pam` + * `C:\Program Files\Keyfactor\Keyfactor Platform\KeyfactorAPI\Extensions\akeyless-pam` + +
+ +
Keyfactor Command 10 + + 1. Copy the assemblies to each of the following directories: + + * `C:\Program Files\Keyfactor\Keyfactor Platform\WebAgentServices\bin\akeyless-pam` + * `C:\Program Files\Keyfactor\Keyfactor Platform\KeyfactorAPI\bin\akeyless-pam` + * `C:\Program Files\Keyfactor\Keyfactor Platform\WebConsole\bin\akeyless-pam` + * `C:\Program Files\Keyfactor\Keyfactor Platform\Service\akeyless-pam` + + 2. Open a text editor on the Keyfactor Command server as an administrator and open the `web.config` file located in the `WebAgentServices` directory. + + 3. In the `web.config` file, locate the ` ` section and add the following registration: + + ```xml + + ... + + + + + + ``` + + 4. Repeat steps 2 and 3 for each of the directories listed in step 1. The configuration files are located in the following paths by default: + + * `C:\Program Files\Keyfactor\Keyfactor Platform\WebAgentServices\web.config` + * `C:\Program Files\Keyfactor\Keyfactor Platform\KeyfactorAPI\web.config` + * `C:\Program Files\Keyfactor\Keyfactor Platform\WebConsole\web.config` + * `C:\Program Files\Keyfactor\Keyfactor Platform\Service\CMSTimerService.exe.config` + +
+ +3. Restart the Keyfactor Command services (`iisreset`). + + + + +#### Install PAM provider on a Universal Orchestrator Host (Remote) + + +1. Install the Akeyless PAM Provider assemblies. + + * **Using kfutil**: On the server that that hosts the Universal Orchestrator, run the following command: + + ```shell + # Windows Server + kfutil orchestrator extension -e akeyless-pam@latest --out "C:\Program Files\Keyfactor\Keyfactor Orchestrator\extensions" + + # Linux + kfutil orchestrator extension -e akeyless-pam@latest --out "/opt/keyfactor/orchestrator/extensions" + ``` + + * **Manually**: Download the latest release of the Akeyless PAM Provider from the [Releases](../../releases) page. Extract the contents of the archive to: + + * **Windows Server**: `C:\Program Files\Keyfactor\Keyfactor Orchestrator\extensions\akeyless-pam` + * **Linux**: `/opt/keyfactor/orchestrator/extensions/akeyless-pam` + +2. Included in the release is a `manifest.json` file that contains the following object: + ```json + + { + "Keyfactor:PAMProviders:Akeyless-:InitializationInfo": { + "Url": "https://api.akeyless.io", + "AuthType": "access_key", + "AccessId": "", + "AccessKey": "" + } + } + + ``` + + Populate the fields in this object with credentials and configuration data collected in the [requirements](docs/akeyless.md#requirements) section. + +3. Restart the Universal Orchestrator service. + + + + + + + + + +### Usage + + + + + + +#### From Keyfactor Command Host (Local) + + + +##### Define a PAM provider in Command +1. In the Keyfactor Command Portal, hover over the ⚙️ (settings) icon in the top right corner of the screen and select **Priviledged Access Management**. + +2. Select the **Add** button to create a new PAM provider. Click the dropdown for **Provider Type** and select **Akeyless**. + +> [!IMPORTANT] +> If you're running Keyfactor Command 11+, make sure `Remote Provider` is unchecked. + +3. Populate the fields with the necessary information collected in the [requirements](docs/akeyless.md#requirements) section: + +| Initialization parameter | Display Name | Description | +| --- | --- | --- | +| Url | Akeyless URL | The URL to the Akeyless instance. Defaults to: https://api.akeyless.io | +| AccessId | Access ID | The access key ID used to authenticate to Akeyless using `access_key` authentication. | +| AccessKey | Access Key | The access key used to authenticate to Akeyless using `access_key` authentication. | +| AuthType | Auth Type | The auth type used to authenticate to the Akeyless platform. Supported types are `access_key`. | + + +4. Click **Save**. The PAM provider is now available for use in Keyfactor Command. + +##### Using the PAM provider + +Now, when defining Certificate Stores (**Locations**->**Certificate Stores**), **Akeyless** will be available as a PAM provider option. When defining new Certificate Stores, the secret parameter form will display tabs for **Load From Keyfactor Secrets** or **Load From PAM Provider**. + +Select the **Load From PAM Provider** tab, choose the **Akeyless** provider from the list of **Providers**, and populate the fields with the necessary information from the table below: + +| Instance parameter | Display Name | Description | +| --- | --- | --- | +| SecretName | Secret Name | The full name (path) of the secret in Akeyless that contains the credential to retrieve. | +| SecretType | Secret Type | The type of secret stored in Akeyless. Supported types are `static_kv,static_text,static_json`. | +| StaticSecretFieldName | Static Secret Field Name | The field name within a static secret to retrieve the credential from. Required for `static_kv` and optional for `static_json` secret types. | + + + + + +#### From a Universal Orchestrator Host (Remote) + + + +
Keyfactor Command 11+ + +##### Define a remote PAM provider in Command + +In Command 11 and greater, before using the Akeyless PAM type, you must define a Remote PAM Provider in the Command portal. + +1. In the Keyfactor Command Portal, hover over the ⚙️ (settings) icon in the top right corner of the screen and select **Priviledged Access Management**. + +2. Select the **Add** button to create a new PAM provider. + +3. Make sure that `Remote Provider` is checked. + +4. Click the dropdown for **Provider Type** and select **Akeyless**. + +5. Give the provider a unique name. + +6. Click "Save". + +##### Using the PAM provider + +When defining Certificate Stores (**Locations**->**Certificate Stores**), **Akeyless** can be used as a PAM provider. When defining a new Certificate Store, the secret parameter form will display tabs for **Load From Keyfactor Secrets** or **Load From PAM Provider**. + +Select the **Load From PAM Provider** tab, choose the **Akeyless** provider from the list of **Providers**, and populate the fields with the necessary information from the table below: + +| Instance parameter | Display Name | Description | +| --- | --- | --- | +| SecretName | Secret Name | The full name (path) of the secret in Akeyless that contains the credential to retrieve. | +| SecretType | Secret Type | The type of secret stored in Akeyless. Supported types are `static_kv,static_text,static_json`. | +| StaticSecretFieldName | Static Secret Field Name | The field name within a static secret to retrieve the credential from. Required for `static_kv` and optional for `static_json` secret types. | + + +
+ +
Keyfactor Command 10 + +When defining Certificate Stores (**Locations**->**Certificate Stores**), **Akeyless** can be used as a PAM provider. + +When entering Secret fields, select the **Load From Keyfactor Secrets** tab, and populate the **Secret Value** field with the following JSON object: + +```json +{"SecretName": "The full name (path) of the secret in Akeyless that contains the credential to retrieve.","SecretType": "The type of secret stored in Akeyless. Supported types are `static_kv,static_text,static_json`.","StaticSecretFieldName": "The field name within a static secret to retrieve the credential from. Required for `static_kv` and optional for `static_json` secret types."} + +``` + +> We recommend creating this JSON object in a text editor, and copying it into the Secret Value field. + +
+ + + + + + +> [!NOTE] +> Additional information on Akeyless can be found in the [supplemental documentation](docs/akeyless.md). + + + +## License + +Apache License 2.0, see [LICENSE](LICENSE) + +## Related Integrations + +See all [Keyfactor PAM Provider extensions](https://github.com/orgs/Keyfactor/repositories?q=pam). \ No newline at end of file diff --git a/TestConsole/TestConsole.csproj b/TestConsole/TestConsole.csproj deleted file mode 100644 index d9622e2..0000000 --- a/TestConsole/TestConsole.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - Exe - net8.0 - enable - enable - Linux - - - - - .dockerignore - - - - - - - - - - - - - diff --git a/akeyless-pam.sln b/akeyless-pam.sln index b604762..c86f9a2 100644 --- a/akeyless-pam.sln +++ b/akeyless-pam.sln @@ -2,22 +2,65 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "akeyless-pam", "akeyless-pam\akeyless-pam.csproj", "{6DEC0EF0-9D07-44CF-868C-82764C83D285}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestConsole", "TestConsole\TestConsole.csproj", "{90C4CEE8-44EE-4488-B464-4063432051D8}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AkeylessPam.Unit.Tests", "tests\AkeylessPam.Unit.Tests\AkeylessPam.Unit.Tests.csproj", "{5A2FD372-A499-40F7-8448-1955FC09F591}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AkeylessPam.Integration.Tests", "tests\AkeylessPam.Integration.Tests\AkeylessPam.Integration.Tests.csproj", "{D3F87543-EB4C-4242-8668-255D04A2113D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Debug|x64.Build.0 = Debug|Any CPU + {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Debug|x86.Build.0 = Debug|Any CPU {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Release|Any CPU.ActiveCfg = Release|Any CPU {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Release|Any CPU.Build.0 = Release|Any CPU - {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {90C4CEE8-44EE-4488-B464-4063432051D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {90C4CEE8-44EE-4488-B464-4063432051D8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {90C4CEE8-44EE-4488-B464-4063432051D8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {90C4CEE8-44EE-4488-B464-4063432051D8}.Release|Any CPU.Build.0 = Release|Any CPU + {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Release|x64.ActiveCfg = Release|Any CPU + {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Release|x64.Build.0 = Release|Any CPU + {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Release|x86.ActiveCfg = Release|Any CPU + {6DEC0EF0-9D07-44CF-868C-82764C83D285}.Release|x86.Build.0 = Release|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Debug|x64.Build.0 = Debug|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Debug|x86.Build.0 = Debug|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Release|Any CPU.Build.0 = Release|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Release|x64.ActiveCfg = Release|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Release|x64.Build.0 = Release|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Release|x86.ActiveCfg = Release|Any CPU + {5A2FD372-A499-40F7-8448-1955FC09F591}.Release|x86.Build.0 = Release|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Debug|x64.ActiveCfg = Debug|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Debug|x64.Build.0 = Debug|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Debug|x86.ActiveCfg = Debug|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Debug|x86.Build.0 = Debug|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Release|Any CPU.Build.0 = Release|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Release|x64.ActiveCfg = Release|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Release|x64.Build.0 = Release|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Release|x86.ActiveCfg = Release|Any CPU + {D3F87543-EB4C-4242-8668-255D04A2113D}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {5A2FD372-A499-40F7-8448-1955FC09F591} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {D3F87543-EB4C-4242-8668-255D04A2113D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/akeyless-pam/AkeylessApiClient.cs b/akeyless-pam/AkeylessApiClient.cs new file mode 100644 index 0000000..f6d6522 --- /dev/null +++ b/akeyless-pam/AkeylessApiClient.cs @@ -0,0 +1,42 @@ +// 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 System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using akeyless.Api; +using akeyless.Client; +using akeyless.Model; + +namespace Keyfactor.Extensions.Pam.Akeyless; + +internal class AkeylessApiClient : IAkeylessApiClient +{ + private readonly V2Api _api; + + internal AkeylessApiClient(string basePath) + { + var config = new Configuration { BasePath = basePath }; + _api = new V2Api(config); + } + + public string Authenticate(string accessId, string accessKey) + { + var authResp = _api.Auth(new Auth(accessId, accessKey)); + if (string.IsNullOrEmpty(authResp.Token) && string.IsNullOrEmpty(authResp.Creds?.Token)) + return string.Empty; + return string.IsNullOrEmpty(authResp.Token) ? authResp.Creds.Token : authResp.Token; + } + + public async Task> GetSecretValuesAsync(IEnumerable names, string token) + { + var nameList = names.ToList(); + var req = new GetSecretValue(names: nameList, token: token); + var result = await _api.GetSecretValueAsync(req); + return result.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? string.Empty); + } +} diff --git a/akeyless-pam/AkeylessPam.cs b/akeyless-pam/AkeylessPam.cs new file mode 100644 index 0000000..bd0ab7f --- /dev/null +++ b/akeyless-pam/AkeylessPam.cs @@ -0,0 +1,607 @@ +// 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 System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using akeyless.Client; +using Keyfactor.Extensions.Pam.Akeyless.Models; +using Keyfactor.Logging; +using Keyfactor.Platform.Extensions; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.Pam.Akeyless; + +/// +/// Exception thrown when the authentication token for Akeyless is invalid or cannot be obtained. +/// +/// +/// This exception is typically thrown when authentication credentials are incorrect or the server rejects the auth +/// request. +/// +public class InvalidTokenException : Exception +{ + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public InvalidTokenException(string message) : base(message) + { + } +} + +/// +/// Exception thrown when the client configuration for connecting to Akeyless is invalid or incomplete. +/// +/// +/// This exception is typically thrown when required connection or authentication parameters are missing or incorrect. +/// +public class InvalidClientConfigurationException : Exception +{ + /// + /// Initializes a new instance of the class with a specified error + /// message. + /// + /// The message that describes the error. + public InvalidClientConfigurationException(string message) : base(message) + { + } +} + +/// +/// Exception thrown when the secret configuration for Akeyless is invalid or incomplete. +/// +/// +/// This exception is typically thrown when required secret parameters are missing or improperly configured. +/// +public class InvalidSecretConfigurationException : Exception +{ + /// + /// Initializes a new instance of the class with a specified error + /// message. + /// + /// The message that describes the error. + public InvalidSecretConfigurationException(string message) : base(message) + { + } +} + +/// +/// Privileged Access Management (PAM) provider implementation for Akeyless. +/// +/// +/// This class implements the IPAMProvider interface to retrieve secrets from Akeyless. +/// +public class AkeylessPam : IPAMProvider +{ + private readonly Func _clientFactory; + + private ILogger Logger { get; } = LogHandler.GetClassLogger(); + + private string AuthToken { get; set; } = string.Empty; + + public AkeylessPam() : this(basePath => new AkeylessApiClient(basePath)) + { + } + + internal AkeylessPam(Func clientFactory) + { + _clientFactory = clientFactory; + } + + /// + /// Gets the name of this PAM provider. + /// + /// The string "Akeyless". + public string Name => "Akeyless"; + + /// + /// Retrieves a password from Akeyless using the provided configuration parameters. + /// + /// Dictionary containing instance-specific parameters like SecretId and SecretFieldName. + /// + /// Dictionary containing connection and authentication parameters such as host URL, + /// username, and password. + /// + /// The password value retrieved from Akeyless. + /// Thrown when required parameters are missing or invalid. + /// Thrown when authentication with Akeyless fails. + /// Thrown when communication with Akeyless fails. + public string GetPassword(Dictionary instanceParameters, + Dictionary serverConfigurationParameters) + { + try + { + Logger.MethodEntry(); + Logger.LogDebug("Akeyless PAM Provider invoked"); + // NOTE: Neither parameter dictionary is logged here — serverConfigurationParameters contains + // AccessId/AccessKey, and instanceParameters may gain credential-bearing keys in future versions. + Logger.LogTrace("instanceParameters keys: [{Keys}]", string.Join(", ", instanceParameters.Keys)); + + var config = BuildAkeylessConfiguration(instanceParameters, serverConfigurationParameters); + return GetAkeylessSecretAsync(config).Result; + } + finally + { + Logger.MethodExit(); + } + } + + private IAkeylessApiClient InitClient(AkeylessConfiguration configurationInfo) + { + try + { + Logger.MethodEntry(); + var basePath = Environment.GetEnvironmentVariable("AKEYLESS_API_URL") ?? + configurationInfo.Url ?? "https://api.akeyless.io"; + + 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; + + default: + Logger.LogWarning( + "No authentication performed for unrecognised auth type '{AuthType}'", + configurationInfo.AuthType); + break; + } + + 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); + throw new InvalidClientConfigurationException( + $"Unable to authenticate to Akeyless API (HTTP {ex.ErrorCode}). Check AccessId and AccessKey configuration."); + } + finally + { + Logger.MethodExit(); + } + } + + private static bool LooksLikeJson(string s) + { + s = s.Trim(); + return (s.StartsWith('{') && s.EndsWith('}')) || (s.StartsWith('[') && s.EndsWith(']')); + } + + private string ParseJsonSecret(string secretValueStr, string fieldName = "") + { + try + { + Logger.MethodEntry(); + var jsonObj = JsonConvert.DeserializeObject>(secretValueStr); + if (string.IsNullOrEmpty(fieldName)) + { + Logger.LogDebug("No field name specified; returning full JSON blob"); + return secretValueStr; + } + + if (jsonObj != null && jsonObj.TryGetValue(fieldName, out var fieldValue)) + { + Logger.LogDebug("Successfully extracted field '{FieldName}' from JSON secret", fieldName); + return fieldValue.ToString() ?? string.Empty; + } + + Logger.LogError("JSON secret does not contain the specified field '{FieldName}'", fieldName); + throw new InvalidSecretConfigurationException( + $"Secret does not contain the specified field '{fieldName}'"); + } + catch (JsonException ex) + { + Logger.LogError(ex, "Failed to parse secret value as JSON"); + throw; + } + finally + { + Logger.MethodExit(); + } + } + + private string ParseKvSecret(string secretValueStr, string fieldName) + { + try + { + Logger.MethodEntry(); + var lineIndex = 0; + foreach (var line in secretValueStr.Split('\n')) + { + lineIndex++; + var parts = line.Split('=', 2); + if (parts.Length != 2) + { + Logger.LogWarning("Skipping malformed KV entry at line {LineIndex}", lineIndex); + continue; + } + + var k = parts[0].Trim(); + // NOTE: value is intentionally not logged to prevent secret exposure. + Logger.LogDebug("Evaluating KV key '{Key}' at line {LineIndex}", k, lineIndex); + if (k != fieldName) continue; + + Logger.LogDebug("Successfully extracted field '{FieldName}' from KV secret", fieldName); + return parts[1].Trim(); + } + + Logger.LogError("KV secret does not contain the specified field '{FieldName}'", fieldName); + throw new InvalidSecretConfigurationException( + $"Secret does not contain the specified field '{fieldName}'"); + } + finally + { + Logger.MethodExit(); + } + } + + private async Task GetStaticSecret(IAkeylessApiClient client, AkeylessConfiguration configurationInfo) + { + try + { + Logger.MethodEntry(); + Logger.LogDebug("Fetching secret '{SecretName}' (type: {SecretType}) from Akeyless", + configurationInfo.SecretName, configurationInfo.SecretType); + + var secrets = await client.GetSecretValuesAsync([configurationInfo.SecretName], AuthToken); + + if (!secrets.TryGetValue(configurationInfo.SecretName, out var secretValueStr)) + { + Logger.LogError("Secret '{SecretName}' was not found in Akeyless", + configurationInfo.SecretName); + throw new InvalidSecretConfigurationException( + $"Secret '{configurationInfo.SecretName}' not found in Akeyless"); + } + + if (string.IsNullOrEmpty(secretValueStr)) + { + Logger.LogError("Secret '{SecretName}' exists in Akeyless but has an empty value", + configurationInfo.SecretName); + throw new InvalidSecretConfigurationException( + $"Secret '{configurationInfo.SecretName}' is empty"); + } + + string result; + if (LooksLikeJson(secretValueStr)) + { + Logger.LogDebug("Secret '{SecretName}' value is JSON-formatted", configurationInfo.SecretName); + result = configurationInfo.SecretType is "static_json" or "static_kv" + ? ParseJsonSecret(secretValueStr, configurationInfo.StaticSecretFieldName) + : secretValueStr; + } + else if (secretValueStr.Contains('=') && secretValueStr.Contains('\n')) + { + Logger.LogDebug("Secret '{SecretName}' value is KV-formatted", configurationInfo.SecretName); + result = ParseKvSecret(secretValueStr, configurationInfo.StaticSecretFieldName); + } + else + { + Logger.LogDebug("Secret '{SecretName}' value is plain text", configurationInfo.SecretName); + result = secretValueStr; + } + + Logger.LogInformation( + "Successfully retrieved secret '{SecretName}' (type: {SecretType}) from Akeyless", + configurationInfo.SecretName, configurationInfo.SecretType); + return result; + } + finally + { + Logger.MethodExit(); + } + } + + /// + /// Asynchronously retrieves a secret from Akeyless. + /// + /// The configuration containing connection and request details. + /// The value of the requested secret field. + /// Thrown when the HTTP request to Akeyless fails. + /// Thrown when deserializing the response fails or the requested secret is not found. + private async Task GetAkeylessSecretAsync(AkeylessConfiguration configurationInfo) + { + try + { + Logger.MethodEntry(); + Logger.LogDebug("Connecting to Akeyless at '{Url}'", configurationInfo.Url); + var client = InitClient(configurationInfo); + + switch (configurationInfo.SecretType) + { + case "static_text": + case "static_kv": + case "static_json": + return await GetStaticSecret(client, configurationInfo); + default: + Logger.LogError("Unsupported secret type '{SecretType}' — valid types are: {ValidTypes}", + configurationInfo.SecretType, + string.Join(", ", AkeylessConfiguration.SupportedSecretTypes)); + throw new Exception( + $"Invalid secret type '{configurationInfo.SecretType}' specified, please use one of [{string.Join(", ", AkeylessConfiguration.SupportedSecretTypes)}]"); + } + } + finally + { + Logger.MethodExit(); + } + } + + /// + /// Validates the instance parameters provided to the PAM provider. + /// + /// + /// A read-only dictionary containing instance-specific parameters, such as SecretId and SecretFieldName. + /// + /// + /// True if the instance parameters are valid; otherwise, throws an . + /// + /// + /// Thrown if required parameters are missing or cannot be parsed as expected. + /// + private bool ValidateInstanceParams(IReadOnlyDictionary instanceParameters) + { + try + { + Logger.MethodEntry(); + Logger.LogDebug("Validating instance parameters"); + ValidateRequiredParameter(instanceParameters, AkeylessConfiguration.SECRET_NAME, + "instance configuration parameter"); + Logger.LogDebug("Instance parameters are valid"); + return true; + } + catch (MissingFieldException ex) + { + Logger.LogError(ex, "Instance parameter validation failed"); + return false; + } + finally + { + Logger.MethodExit(); + } + } + + /// + /// Validates the server configuration parameters for connecting to Akeyless. + /// + /// + /// A read-only dictionary containing server configuration parameters such as URL, credentials, and grant + /// type. + /// + /// + /// The auth type to use for interacting with the Akeyless API. Supported values are "access_key". + /// Defaults to "access_key". + /// + /// + /// True if the server configuration parameters are valid; otherwise, throws an + /// . + /// + /// + /// Thrown if required parameters are missing or invalid for the specified grant type. + /// + private bool ValidateServerConfigurationParams( + IReadOnlyDictionary connectionConfiguration, + string authType = AkeylessConstants.DefaultAuthMethod) + { + try + { + Logger.MethodEntry(); + Logger.LogDebug("Validating server configuration parameters for auth type '{AuthType}'", authType); + + switch (authType) + { + case "implicit": + Logger.LogWarning("No credential validation performed for 'implicit' auth type"); + break; + + case "access_key": + Logger.LogDebug("Validating access_key credentials"); + ValidateAuthTypeAccessKey(connectionConfiguration); + break; + default: + Logger.LogError("Unsupported auth type '{AuthType}' specified in server configuration", authType); + Logger.MethodExit(); + throw new Exception( + $"Invalid auth type '{authType}' specified."); + } + + Logger.LogDebug("Server configuration parameters are valid"); + return true; + } + finally + { + Logger.MethodExit(); + } + } + + /// + /// Validates that a required parameter exists and is not null or empty in the provided configuration dictionary. + /// + /// + /// The configuration dictionary to validate. + /// + /// + /// The name of the parameter to check for existence and non-empty value. + /// + /// + /// A string prefix to include in the error message if validation fails. + /// + /// + /// Thrown if the required parameter is missing or its value is null or empty. + /// + private void ValidateRequiredParameter( + IReadOnlyDictionary config, + string paramName, + string errorPrefix) + { + try + { + Logger.MethodEntry(); + Logger.LogDebug("Validating required parameter '{ParamName}'", paramName); + + if (config.TryGetValue(paramName, out var value) && !string.IsNullOrEmpty(value)) return; + Logger.LogError("{ErrorPrefix} '{ParamName}' is required but was not provided", errorPrefix, paramName); + throw new MissingFieldException($"{errorPrefix} '{paramName}' not provided"); + } + finally + { + Logger.MethodExit(); + } + } + + /// + /// Validates that the required client ID and client secret parameters exist and are not empty for the client + /// credentials grant type. + /// + /// + /// The configuration dictionary containing client parameters. + /// + /// + /// Thrown if the client ID or client secret parameter is missing or empty. + /// + private void ValidateAuthTypeAccessKey(IReadOnlyDictionary config) + { + try + { + Logger.MethodEntry(); + ValidateRequiredParameter(config, AkeylessConfiguration.ACCESS_KEY, "client configuration parameter"); + ValidateRequiredParameter(config, AkeylessConfiguration.ACCESS_ID, "client configuration parameter"); + } + catch (MissingFieldException ex) + { + Logger.LogError(ex, "Access key authentication parameter validation failed"); + throw new InvalidClientConfigurationException(ex.Message); + } + finally + { + Logger.MethodExit(); + } + } + + /// + /// Creates an AkeylessConfiguration object from the provided parameters. + /// + /// + /// Dictionary containing instance-specific parameters, including the secret name and field + /// name. + /// + /// Dictionary containing connection and authentication parameters for Akeyless. + /// A fully populated AkeylessConfiguration object. + /// Thrown when required parameters are missing or invalid. + private AkeylessConfiguration BuildAkeylessConfiguration( + IReadOnlyDictionary instanceParameters, + IReadOnlyDictionary connectionConfiguration) + { + try + { + Logger.MethodEntry(); + Logger.LogDebug("Building and validating Akeyless configuration"); + var validServer = ValidateServerConfigurationParams(connectionConfiguration); + var validInstance = ValidateInstanceParams(instanceParameters); + + if (!validServer || !validInstance) + { + Logger.LogError("Akeyless PAM provider configuration is invalid; see preceding log entries for details"); + throw new InvalidClientConfigurationException( + "Akeyless configuration is invalid, please review server logs."); + } + + if (!connectionConfiguration.TryGetValue(AkeylessConfiguration.AUTH_TYPE, out var authType)) + { + Logger.LogWarning( + "'{AuthType}' parameter not provided; defaulting to 'access_key'", + AkeylessConfiguration.AUTH_TYPE); + authType = "access_key"; + } + + var config = new AkeylessConfiguration + { + Url = connectionConfiguration.GetValueOrDefault(AkeylessConfiguration.AKEYLESS_API_URL, + AkeylessConstants.DefaultAkeylessApiUrl), + AuthType = authType + }; + Logger.LogDebug("Using Akeyless URL '{Url}', auth type '{AuthType}'", config.Url, config.AuthType); + + switch (authType) + { + case "implicit": + 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]; + // NOTE: AccessId logged (not secret), AccessKey intentionally omitted. + Logger.LogDebug("Access key auth configured with AccessId '{AccessId}'", config.AccessId); + break; + default: + Logger.LogError("Unsupported auth type '{AuthType}' encountered during configuration build", authType); + throw new Exception($"Invalid grant type '{authType}' specified"); + } + + config.SecretType = instanceParameters.GetValueOrDefault(AkeylessConfiguration.SECRET_TYPE, ""); + config.SecretName = instanceParameters[AkeylessConfiguration.SECRET_NAME]; + Logger.LogDebug("Configured to retrieve secret '{SecretName}' (type: '{SecretType}')", + config.SecretName, config.SecretType); + + switch (config.SecretType) + { + case "static_kv": + config.StaticSecretFieldName = instanceParameters[AkeylessConfiguration.STATIC_SECRET_FIELD_NAME].Trim(); + Logger.LogDebug("KV field name set to '{FieldName}'", config.StaticSecretFieldName); + break; + case "static_json": + config.StaticSecretFieldName = instanceParameters.GetValueOrDefault( + AkeylessConfiguration.STATIC_SECRET_FIELD_NAME, "").Trim(); + if (!string.IsNullOrEmpty(config.StaticSecretFieldName)) + Logger.LogDebug("JSON field name set to '{FieldName}'", config.StaticSecretFieldName); + else + Logger.LogDebug("No JSON field name specified; full JSON blob will be returned"); + break; + } + + var validationContext = new ValidationContext(config); + var validationResults = new List(); + if (!Validator.TryValidateObject(config, validationContext, validationResults, validateAllProperties: true)) + { + var errors = string.Join("; ", validationResults.Select(r => r.ErrorMessage)); + Logger.LogError("Akeyless configuration model validation failed: {Errors}", errors); + throw new InvalidClientConfigurationException( + $"Akeyless configuration validation failed: {errors}"); + } + + Logger.LogDebug("Akeyless configuration built successfully"); + return config; + } + finally + { + Logger.MethodExit(); + } + } +} diff --git a/akeyless-pam/Constants.cs b/akeyless-pam/Constants.cs new file mode 100644 index 0000000..a16a22b --- /dev/null +++ b/akeyless-pam/Constants.cs @@ -0,0 +1,10 @@ +namespace Keyfactor.Extensions.Pam.Akeyless; + +public static class AkeylessConstants +{ + // Compile-time constant (gets inlined into referencing assemblies) + public const string DefaultAuthMethod = "access_key"; + + public const string DefaultAkeylessApiUrl = "https://api.akeyless.io"; + +} \ No newline at end of file diff --git a/akeyless-pam/IAkeylessApiClient.cs b/akeyless-pam/IAkeylessApiClient.cs new file mode 100644 index 0000000..4c076e7 --- /dev/null +++ b/akeyless-pam/IAkeylessApiClient.cs @@ -0,0 +1,25 @@ +// 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 System.Collections.Generic; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.Pam.Akeyless; + +public interface IAkeylessApiClient +{ + /// + /// Authenticates to Akeyless using the provided access key credentials. + /// + /// The auth token, or empty string if authentication failed. + string Authenticate(string accessId, string accessKey); + + /// + /// Retrieves secret values by name from Akeyless. + /// + Task> GetSecretValuesAsync(IEnumerable names, string token); +} diff --git a/akeyless-pam/Models/AkeylessConfiguration.cs b/akeyless-pam/Models/AkeylessConfiguration.cs new file mode 100644 index 0000000..a8b035c --- /dev/null +++ b/akeyless-pam/Models/AkeylessConfiguration.cs @@ -0,0 +1,190 @@ +// 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 System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; + +namespace Keyfactor.Extensions.Pam.Akeyless.Models; + +/// +/// Configuration class for connecting to and retrieving secrets from Akeyless. +/// +internal class AkeylessConfiguration : IValidatableObject +{ + public static readonly ImmutableList SupportedAuthMethods = + ImmutableList.Create(AkeylessConstants.DefaultAuthMethod); + + public static readonly ImmutableList SupportedSecretTypes = ImmutableList.Create( + "static_text", "static_json", "static_kv" + ); + + public static readonly ImmutableList UnsupportedAuthMethods = ImmutableList.Create( + "saml" + ); + + /// + /// Initializes a new instance of the class with empty strings. + /// + /// + /// This constructor initializes required string properties with empty strings to satisfy nullable requirements. + /// Validation logic in the Validate method ensures actual values are provided when needed. + /// + public AkeylessConfiguration() + { + // Initialize non-nullable string properties to satisfy compiler + Url = string.Empty; + AccessId = string.Empty; + AccessKey = string.Empty; + SecretName = string.Empty; + StaticSecretFieldName = string.Empty; + AuthType = "access_key"; // Default value is already set in property declaration + } + + /// + /// The configuration for the AKeyless API URL. + /// + public static string AKEYLESS_API_URL => "Url"; + + /// + /// The configuration key for the auth type used for authentication. For more information on auth_types see + /// https://docs.akeyless.io/reference/auth `access-type`. + /// + /// + /// Supported auth types: + /// - `access_key`: Uses AccessId and AccessKey for authentication. + /// + public static string AUTH_TYPE => "AuthType"; + + /// + /// The configuration key for the access ID used in access key authentication. + /// + public static string ACCESS_ID => "AccessId"; + + /// + /// The configuration key for the access key used in access key authentication. + /// + public static string ACCESS_KEY => "AccessKey"; + + /// + /// The configuration key for the type of secret to retrieve from Akeyless. For more information on secret types see + /// https://docs.akeyless.io/docs/manage-your-secrets-overview + /// + /// + /// Supported secret types: + /// - `static_text`: A static text secret. + /// - `static_json`: A static JSON secret. + /// - `static_kv`: A static key-value secret. + /// + public static string SECRET_TYPE => "SecretType"; + + /// + /// The configuration key for the name of the secret to retrieve from Akeyless. + /// + public static string SECRET_NAME => "SecretName"; + + /// + /// The configuration key for the field name within the secret to retrieve. + /// + public static string STATIC_SECRET_FIELD_NAME => "StaticSecretFieldName"; + + /// + /// The base URL of the Akeyless Secret Server. + /// + /// + /// Defaults to the public Akeyless API URL if not specified. + /// + public string Url { get; init; } + + /// + /// The access ID for access_key authentication with Akeyless. + /// For more information see https://docs.akeyless.io/docs/api-key + /// + public string AccessId { get; set; } + + /// + /// The access key for access_key authentication with Akeyless. + /// For more information see https://docs.akeyless.io/docs/api-key + /// + public string AccessKey { get; set; } + + /// + /// The type of the secret to retrieve from Akeyless. + /// + /// + /// Must be one of: + /// - `static_text` + /// - `static_json` + /// - `static_kv`. + /// Defaults to `static_text`. + /// + [Required(ErrorMessage = "The SecretType field is required.")] + [RegularExpression("^(static_text|static_json|static_kv)$", + ErrorMessage = "SecretType must be one of `[static_text,static_json,static_kv]`.")] + public string SecretType { get; set; } = "static_text"; + + /// + /// The identifier of the secret to retrieve from Akeyless. + /// + [Required(ErrorMessage = "The SecretName field is required.")] + public string SecretName { get; set; } + + /// + /// The name of the field within the secret to retrieve. + /// This can be either the field name or slug. + /// + public string StaticSecretFieldName { get; set; } + + /// + /// The auth type to use for authentication to AKeyless. + /// + /// + /// Defaults to `access_key`. + /// Unsupported auth types: + /// - `saml`: Due to requiring a web browser for SAML assertions. + /// + [RegularExpression( + "^(access_key|password|ldap|k8s|azure_ad|oidc|aws_iam|universal_identity|jwt|gcp|cert|oci|kerberos)$", + ErrorMessage = + "AuthType must be one of `[access_key,password,ldap,k8s,azure_ad,oidc,aws_iam,universal_identity,jwt,gcp,cert,oci,kerberos]`.")] + public string AuthType { get; init; } + + /// + /// Validates that the configuration has either username/password or client credentials for authentication. + /// + /// The validation context. + /// A collection of validation results. + public IEnumerable Validate(ValidationContext validationContext) + { + switch (AuthType) + { + case AkeylessConstants.DefaultAuthMethod: + if (string.IsNullOrWhiteSpace(AccessId) || string.IsNullOrWhiteSpace(AccessKey)) + yield return new ValidationResult( + $"AccessId and AccessKey must be provided for '{AkeylessConstants.DefaultAuthMethod}' authentication.", + [nameof(AccessId), nameof(AccessKey)]); + break; + default: + yield return new ValidationResult( + $"Unsupported AuthType. Currently, only '{string.Join(", ", SupportedAuthMethods)}' are supported.", + [nameof(AuthType)]); + break; + } + + if (!SupportedSecretTypes.Contains(SecretType)) + yield return new ValidationResult( + $"Unsupported SecretType. Supported types are: {string.Join(", ", SupportedSecretTypes)}.", + [nameof(SecretType)] + ); + // StaticSecretFieldName is required for static_kv, optional for static_json + if (SecretType == "static_kv" && string.IsNullOrWhiteSpace(StaticSecretFieldName)) + yield return new ValidationResult( + "StaticSecretFieldName must be provided when SecretType is 'static_kv'.", + [nameof(StaticSecretFieldName)] + ); + } +} \ No newline at end of file diff --git a/akeyless-pam/akeyless-pam.csproj b/akeyless-pam/akeyless-pam.csproj index d5e085b..3da62bc 100644 --- a/akeyless-pam/akeyless-pam.csproj +++ b/akeyless-pam/akeyless-pam.csproj @@ -1,17 +1,15 @@ - net6.0;net8.0;net9.0 + net8.0;net10.0 true Keyfactor.Extensions.PAM.Akeyless latest true true Keyfactor.PAM.Akeyless - - true - portable - false + + true @@ -19,6 +17,11 @@ false + + portable + true + + @@ -39,6 +42,10 @@ + + + + @@ -52,4 +59,17 @@ + + + <_Parameter1>AkeylessPam.Unit.Tests + + + <_Parameter1>AkeylessPam.Integration.Tests + + + + <_Parameter1>DynamicProxyGenAssembly2 + + + \ No newline at end of file diff --git a/akeyless-pam/manifest.json b/akeyless-pam/manifest.json new file mode 100644 index 0000000..8a2de1b --- /dev/null +++ b/akeyless-pam/manifest.json @@ -0,0 +1,16 @@ +{ + "extensions": { + "Keyfactor.Platform.Extensions.IPAMProvider": { + "PAMProviders.Akeyless.PAMProvider": { + "assemblyPath": "akeyless-pam.dll", + "TypeFullName": "Keyfactor.Extensions.Pam.Akeyless.AkeylessPam" + } + } + }, + "Keyfactor:PAMProviders:Akeyless-:InitializationInfo": { + "Url": "https://api.akeyless.io", + "AuthType": "access_key", + "AccessId": "", + "AccessKey": "" + } +} \ No newline at end of file diff --git a/docs/akeyless.md b/docs/akeyless.md new file mode 100644 index 0000000..e7be682 --- /dev/null +++ b/docs/akeyless.md @@ -0,0 +1,162 @@ +## Akeyless + +The Akeyless PAM Provider allows for the retrieval of stored account credentials from an Akeyless secret. +Below you will find a list of supported [auth methods](#supported-authentication-methods) and [secret types](#supported-secret-types) for this provider. For more information on +these authentication methods, see the [Akeyless documentation](https://docs.akeyless.io/reference/auth) + +## 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. + + + + +## Supported Authentication Methods + +### Access Key (API Key) Authentication +This method uses an Access Key and Access ID pair to authenticate to the Akeyless API. These credentials can be created in the Akeyless console. +For more information, see the [Akeyless documentation](https://tutorials.akeyless.io/docs/authentication-methods-and-api-key-authentication). + +#### Example `manifest.json` configuration: + +```json +{ + "extensions": { + "Keyfactor.Platform.Extensions.IPAMProvider": { + "PAMProviders.Akeyless.PAMProvider": { + "assemblyPath": "akeyless-pam.dll", + "TypeFullName": "Keyfactor.Extensions.Pam.Akeyless.AkeylessPam" + } + } + }, + "Keyfactor:PAMProviders:Akeyless-:InitializationInfo": { + "Url": "https://api.akeyless.io", + "AuthType": "access_key", + "AccessId": "", + "AccessKey": "" + } +} +``` + +## Supported Secret Types +Below are the types of Akeyless secret that are supported by this provider. + +### Static Secrets +For full details on static secrets, see the [Akeyless documentation](https://docs.akeyless.io/docs/secret-management/static-secrets). + +| Secret Type | Description | Additional Fields | +|---------------|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `static_text` | A static secret whose value is returned as a plain string | N/A | +| `static_json` | A static secret containing JSON; a specific field can optionally be extracted | *Optional*: `StaticSecretFieldName`. Use this to parse a specific field value from a JSON secret, else the full JSON blob will be returned | +| `static_kv` | A static secret containing key-value pairs; a specific field is extracted by name | *Required*: `StaticSecretFieldName`. Use this to parse a specific field value from a key-value secret. For example `password`. | + +--- + +#### `static_text` + +A static secret whose entire value is a plain string. The value is returned as-is with no parsing. + +**Example secret value in Akeyless:** +``` +s3cr3tP@ssword! +``` + +**Example instance parameter configuration:** + +| Parameter | Value | +|-----------|-------| +| `SecretName` | `/my-org/my-app/db-password` | +| `SecretType` | `static_text` | + +--- + +#### `static_json` + +A static secret whose value is a JSON object. The provider can return either the full JSON blob or a single extracted field. + +- If `StaticSecretFieldName` is **omitted**, the full JSON string is returned. +- If `StaticSecretFieldName` is **provided**, only the value of that field is returned. + +> **Note:** The Keyfactor Command portal may display `StaticSecretFieldName` as a required field. If you want the full JSON blob returned (no field extraction), enter a single space (` `) in the field — the provider treats whitespace-only values as empty. + +**Example secret value in Akeyless:** +```json +{ + "username": "db_user", + "password": "s3cr3tP@ssword!" +} +``` + +**Example instance parameter configuration (extract a single field):** + +| Parameter | Value | +|-----------|-------| +| `SecretName` | `/my-org/my-app/db-credentials` | +| `SecretType` | `static_json` | +| `StaticSecretFieldName` | `password` | + +--- + +#### `static_kv` + +A static secret whose value is a set of key-value pairs, one per line in `key=value` format. A specific field must be named via `StaticSecretFieldName`. + +**Example secret value in Akeyless:** +``` +username=db_user +password=s3cr3tP@ssword! +host=db.example.com +``` + +**Example instance parameter configuration:** + +| Parameter | Value | +|-----------|-------| +| `SecretName` | `/my-org/my-app/db-credentials` | +| `SecretType` | `static_kv` | +| `StaticSecretFieldName` | `password` | + +## Mechanics + +When configuring Akeyless for use as a PAM Provider with Keyfactor, you will need to ensure that your +instance is configured for API access using the desired auth method. This can be done by an Akeyless administrator. +For more details visit the vendor +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. + +### 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. + +**1. Create an Access Role** (if one doesn't exist already) + +Navigate to **Access Roles** → **New Role**, give it a name (e.g. `keyfactor-pam`), and save. + +**2. Associate the Auth Method with the Role** + +Open the role, go to the **Auth Methods** tab, and click **Associate**. Select the API Key auth method whose Access ID and Access Key you'll be configuring in Keyfactor. + +**3. Add a secret access rule to the Role** + +Still in the role, go to the **Access Rules** (or **Items**) tab and click **Add Rule**: + +| Field | Value | +|---|---| +| Item path | The full path to your secret, e.g. `/my-org/my-app/db-password`. Wildcards are supported, e.g. `/my-org/my-app/*` | +| Access type | `read` | + +Save the rule. + +Once the rule is in place, the auth method can authenticate and retrieve any secret that matches the configured path. You can verify access using the Akeyless CLI: + +```shell +akeyless auth --access-id --access-key +akeyless get-secret-value --name /my-org/my-app/db-password --token +``` + +### Granting an Auth Method Access to a Secret (CLI) + +The full service account setup can be scripted using the Akeyless CLI. The `create-auth-method-api-key` command returns the Access ID and Access Key you'll need for the Keyfactor configuration. + +```shell diff --git a/docsource/akeyless.md b/docsource/akeyless.md new file mode 100644 index 0000000..b51bd89 --- /dev/null +++ b/docsource/akeyless.md @@ -0,0 +1,218 @@ +## Overview + +The Akeyless PAM Provider allows for the retrieval of stored account credentials from an Akeyless secret. +Below you will find a list of supported [auth methods](#supported-authentication-methods) and [secret types](#supported-secret-types) for this provider. For more information on +these authentication methods, see the [Akeyless documentation](https://docs.akeyless.io/reference/auth) + +## 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. + +## Supported Authentication Methods + +### Access Key (API Key) Authentication +This method uses an Access Key and Access ID pair to authenticate to the Akeyless API. These credentials can be created in the Akeyless console. +For more information, see the [Akeyless documentation](https://tutorials.akeyless.io/docs/authentication-methods-and-api-key-authentication). + +#### Example `manifest.json` configuration: + +```json +{ + "extensions": { + "Keyfactor.Platform.Extensions.IPAMProvider": { + "PAMProviders.Akeyless.PAMProvider": { + "assemblyPath": "akeyless-pam.dll", + "TypeFullName": "Keyfactor.Extensions.Pam.Akeyless.AkeylessPam" + } + } + }, + "Keyfactor:PAMProviders:Akeyless-:InitializationInfo": { + "Url": "https://api.akeyless.io", + "AuthType": "access_key", + "AccessId": "", + "AccessKey": "" + } +} +``` + +## Supported Secret Types +Below are the types of Akeyless secret that are supported by this provider. + +### Static Secrets +For full details on static secrets, see the [Akeyless documentation](https://docs.akeyless.io/docs/secret-management/static-secrets). + +| Secret Type | Description | Additional Fields | +|---------------|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `static_text` | A static secret whose value is returned as a plain string | N/A | +| `static_json` | A static secret containing JSON; a specific field can optionally be extracted | *Optional*: `StaticSecretFieldName`. Use this to parse a specific field value from a JSON secret, else the full JSON blob will be returned | +| `static_kv` | A static secret containing key-value pairs; a specific field is extracted by name | *Required*: `StaticSecretFieldName`. Use this to parse a specific field value from a key-value secret. For example `password`. | + +--- + +#### `static_text` + +A static secret whose entire value is a plain string. The value is returned as-is with no parsing. + +**Example secret value in Akeyless:** +``` +s3cr3tP@ssword! +``` + +**Example instance parameter configuration:** + +| Parameter | Value | +|-----------|-------| +| `SecretName` | `/my-org/my-app/db-password` | +| `SecretType` | `static_text` | + +--- + +#### `static_json` + +A static secret whose value is a JSON object. The provider can return either the full JSON blob or a single extracted field. + +- If `StaticSecretFieldName` is **omitted**, the full JSON string is returned. +- If `StaticSecretFieldName` is **provided**, only the value of that field is returned. + +> **Note:** The Keyfactor Command portal may display `StaticSecretFieldName` as a required field. If you want the full JSON blob returned (no field extraction), enter a single space (` `) in the field — the provider treats whitespace-only values as empty. + +**Example secret value in Akeyless:** +```json +{ + "username": "db_user", + "password": "s3cr3tP@ssword!" +} +``` + +**Example instance parameter configuration (extract a single field):** + +| Parameter | Value | +|-----------|-------| +| `SecretName` | `/my-org/my-app/db-credentials` | +| `SecretType` | `static_json` | +| `StaticSecretFieldName` | `password` | + +--- + +#### `static_kv` + +A static secret whose value is a set of key-value pairs, one per line in `key=value` format. A specific field must be named via `StaticSecretFieldName`. + +**Example secret value in Akeyless:** +``` +username=db_user +password=s3cr3tP@ssword! +host=db.example.com +``` + +**Example instance parameter configuration:** + +| Parameter | Value | +|-----------|-------| +| `SecretName` | `/my-org/my-app/db-credentials` | +| `SecretType` | `static_kv` | +| `StaticSecretFieldName` | `password` | + + +## Mechanics + +When configuring Akeyless for use as a PAM Provider with Keyfactor, you will need to ensure that your +instance is configured for API access using the desired auth method. This can be done by an Akeyless administrator. +For more details visit the vendor +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. + +### 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. + +**1. Create an Access Role** (if one doesn't exist already) + +Navigate to **Access Roles** → **New Role**, give it a name (e.g. `keyfactor-pam`), and save. + +**2. Associate the Auth Method with the Role** + +Open the role, go to the **Auth Methods** tab, and click **Associate**. Select the API Key auth method whose Access ID and Access Key you'll be configuring in Keyfactor. + +**3. Add a secret access rule to the Role** + +Still in the role, go to the **Access Rules** (or **Items**) tab and click **Add Rule**: + +| Field | Value | +|---|---| +| Item path | The full path to your secret, e.g. `/my-org/my-app/db-password`. Wildcards are supported, e.g. `/my-org/my-app/*` | +| Access type | `read` | + +Save the rule. + +Once the rule is in place, the auth method can authenticate and retrieve any secret that matches the configured path. You can verify access using the Akeyless CLI: + +```shell +akeyless auth --access-id --access-key +akeyless get-secret-value --name /my-org/my-app/db-password --token +``` + +### Granting an Auth Method Access to a Secret (CLI) + +The full service account setup can be scripted using the Akeyless CLI. The `create-auth-method-api-key` command returns the Access ID and Access Key you'll need for the Keyfactor configuration. + +```shell +# 1. Create the API Key auth method +# The response includes the Access ID and Access Key — save these. +akeyless create-auth-method-api-key --name /keyfactor/pam-auth-method + +# 2. Create an access role +akeyless create-role --name keyfactor-pam + +# 3. Associate the auth method with the role +akeyless assoc-role-auth-method \ + --role-name keyfactor-pam \ + --am-name /keyfactor/pam-auth-method + +# 4. Grant the role read access to a secret path (wildcards supported) +akeyless set-role-rule \ + --role-name keyfactor-pam \ + --path "/my-org/my-app/*" \ + --capability read +``` + +After adding and sharing a secret, you can use the secret's name (the "Secret name") to retrieve credentials from Akeyless as a PAM Provider. + +### Running the PAM provider on Keyfactor Universal Orchestrator (UO) + +When installing on the Universal Orchestrator (UO), the PAM provider is installed on and run from the UO host. Below is a sequence diagram +showing the flow of the PAM provider when it is run from the UO. + +```mermaid +sequenceDiagram + KeyfactorCommand->>KeyfactorCommand: New job created. + UO->>KeyfactorCommand: Hello do you have any jobs for me? + KeyfactorCommand->>UO: Yes here's a job. + UO->>Akeyless: Hello here are my client credentials. + Akeyless->>UO: Here's your API token. + UO->>Akeyless: I need secret named `my_secret`, here's my API token. + Akeyless->>Akeyless: Check secret ACL. + Akeyless->>UO: This is allowed, here's the secret. + UO->>UO: Running job. + UO->>KeyfactorCommand: Job completed. +``` + +### Running the PAM provider on the Keyfactor Command Host + +When installing the PAM provider on the Keyfactor Command Host, it is installed on and run from the Keyfactor Command host. +Below is a sequence diagram showing the flow of the PAM provider when it is run from the Keyfactor Command Host. + +```mermaid +sequenceDiagram + KeyfactorCommand->>KeyfactorCommand: Creating a new job. + KeyfactorCommand->>Akeyless: Hello here are my credentials. + Akeyless->>KeyfactorCommand: Here's your API token. + KeyfactorCommand->>Akeyless: I need secret named `my_secret`, here's my API token. + Akeyless->>Akeyless: Check secret ACL. + Akeyless->>KeyfactorCommand: This is allowed, here's the secret. + UO->>KeyfactorCommand: Hello do you have any jobs for me? + KeyfactorCommand->>UO: Yes here's a job with these credentials I pulled from Akeyless. + UO->>UO: Running job. + UO->>KeyfactorCommand: Job completed. +``` diff --git a/docsource/overview.md b/docsource/overview.md new file mode 100644 index 0000000..12b4aff --- /dev/null +++ b/docsource/overview.md @@ -0,0 +1,3 @@ +## Overview + +The Akeyless PAM Provider allows for the retrieval of stored account credentials from an Akeyless secret. \ No newline at end of file diff --git a/docsource/testing.md b/docsource/testing.md new file mode 100644 index 0000000..2e8a585 --- /dev/null +++ b/docsource/testing.md @@ -0,0 +1,125 @@ +## Testing + +The test suite is split into two projects under `tests/`: + +| Project | Purpose | +|---|---| +| `AkeylessPam.Unit.Tests` | Pure unit tests — no network, always runnable | +| `AkeylessPam.Integration.Tests` | Tests against a live Akeyless instance — skip automatically when credentials are absent | + +### Running Tests + +A `Makefile` at the repo root provides shortcuts for common tasks: + +```shell +make test-unit # unit tests only +make test-integration # integration tests only +make test # both test projects +``` + +Or use `dotnet` directly: + +```shell +# Unit tests only +dotnet test tests/AkeylessPam.Unit.Tests/ + +# Integration tests only +dotnet test tests/AkeylessPam.Integration.Tests/ +``` + +Integration tests load credentials from environment variables. As a convenience for local development, they also read a `.env` file in the repository root if present. Environment variables always take precedence over `.env` values. + +#### Required environment variables + +| Variable | Description | +|---|---| +| `AKEYLESS_ACCESS_ID` | Akeyless access key ID | +| `AKEYLESS_ACCESS_KEY` | Akeyless access key secret | + +#### Optional environment variables + +| Variable | Default | Description | +|---|---|---| +| `AKEYLESS_API_URL` | `https://api.akeyless.io` | Akeyless API base URL | +| `AKEYLESS_AUTH_TYPE` | `access_key` | Auth type | +| `AKEYLESS_SECRET_STATIC_TEXT` | `pam/test/pamStaticTextUsername` | Path to a `static_text` secret | +| `AKEYLESS_SECRET_STATIC_TEXT_2` | `pam/test/pamStaticTextPassword` | Path to a second `static_text` secret (used in multi-secret test) | +| `AKEYLESS_SECRET_STATIC_KV` | `pam/test/pamStaticKV` | Path to a `static_kv` secret with `username` and `password` fields | +| `AKEYLESS_SECRET_STATIC_JSON` | `pam/test/pamStaticJSON` | Path to a `static_json` secret with `username` and `password` fields | +| `AKEYLESS_SECRET_STATIC_JSON_RAW` | — | Path to a `static_json` secret to retrieve as a raw blob (no field extraction) | + +### Unit Test Cases + +#### Validation (`ValidationTests`) + +| Test | What it verifies | +|---|---| +| `GetPassword_MissingSecretName_ThrowsInvalidClientConfigurationException` | `SecretName` absent in instance parameters → exception | +| `GetPassword_MissingAccessId_ThrowsInvalidClientConfigurationException` | `AccessId` absent in server parameters → exception | +| `GetPassword_MissingAccessKey_ThrowsInvalidClientConfigurationException` | `AccessKey` absent in server parameters → exception | +| `GetPassword_InvalidAuthType_Throws` | Unknown `AuthType` value → exception | +| `GetPassword_InvalidSecretType_ThrowsInvalidClientConfigurationException` | Unknown `SecretType` value → `InvalidClientConfigurationException` (caught at model validation before the async path) | + +#### Authentication (`AuthenticationTests`) + +| Test | What it verifies | +|---|---| +| `GetPassword_AuthenticateReturnsEmptyToken_ThrowsInvalidTokenException` | Mock returns empty token → `InvalidTokenException` | +| `GetPassword_UsesConfiguredUrl_WhenNoEnvVar` | The `Url` server parameter is forwarded to the client factory as `basePath` | + +#### Secret Retrieval (`SecretRetrievalTests`) + +| Test | What it verifies | +|---|---| +| `GetPassword_StaticText_PlainString_ReturnsAsIs` | Plain text secret returned unchanged | +| `GetPassword_StaticText_JsonContent_ReturnsFullJsonBlob` | JSON-shaped content with `SecretType=static_text` returned as raw string | +| `GetPassword_StaticKv_ReturnsMatchingFieldValue` | KV-formatted content, field found → correct value | +| `GetPassword_StaticKv_MissingField_ThrowsInvalidSecretConfigurationException` | KV content, requested field absent → exception | +| `GetPassword_StaticKv_JsonStoredAsKv_ParsesViaJson` | JSON-shaped content with `SecretType=static_kv` is parsed as JSON (auto-detection) | +| `GetPassword_StaticJson_ReturnsSpecifiedField` | JSON content, field name provided → field value | +| `GetPassword_StaticJson_NoFieldName_ReturnsFullBlob` | JSON content, no `StaticSecretFieldName` → full JSON blob | +| `GetPassword_StaticJson_MissingField_ThrowsInvalidSecretConfigurationException` | JSON content, requested field absent → exception | +| `GetPassword_SecretNotInResponse_ThrowsInvalidSecretConfigurationException` | API response does not contain the requested secret name → exception | +| `GetPassword_EmptySecretValue_ThrowsInvalidSecretConfigurationException` | Secret found but value is empty → exception | + +#### Configuration Model (`AkeylessConfigurationTests`) + +| Test | What it verifies | +|---|---| +| `Validate_ValidAccessKeyConfig_NoErrors` | A fully populated valid config produces no validation errors | +| `Validate_MissingAccessId_ReturnsError` | Empty `AccessId` with `access_key` auth → validation error | +| `Validate_MissingAccessKey_ReturnsError` | Empty `AccessKey` with `access_key` auth → validation error | +| `Validate_UnsupportedAuthType_ReturnsError` | Auth type not in the supported list → validation error | +| `Validate_UnsupportedSecretType_ReturnsError` | `SecretType` not in `[static_text, static_kv, static_json]` → validation error | +| `Validate_StaticKvMissingFieldName_ReturnsError` | `static_kv` with empty `StaticSecretFieldName` → validation error | +| `Validate_StaticJsonMissingFieldName_NoError` | `static_json` with empty `StaticSecretFieldName` is valid (field is optional — returns full blob) | +| `SupportedSecretTypes_ContainsExpectedValues` | Static list contains all three supported types | +| `Constants_DefaultAuthMethod_IsAccessKey` | Default auth method constants are `access_key` | +| `Constants_DefaultApiUrl_IsCorrect` | Default API URL is `https://api.akeyless.io` | + +### Integration Test Cases + +#### `AkeylessApiClientTests` — exercises `AkeylessApiClient` directly + +| Test | Requires | +|---|---| +| `Authenticate_ValidCredentials_ReturnsNonEmptyToken` | Credentials | +| `Authenticate_InvalidCredentials_ThrowsApiException` | Credentials | +| `GetSecretValuesAsync_StaticTextSecret_ReturnsDictWithValue` | Credentials, `AKEYLESS_SECRET_STATIC_TEXT` | +| `GetSecretValuesAsync_StaticKvSecret_ReturnsDictWithValue` | Credentials, `AKEYLESS_SECRET_STATIC_KV` | +| `GetSecretValuesAsync_StaticJsonSecret_ReturnsDictWithValue` | Credentials, `AKEYLESS_SECRET_STATIC_JSON` | +| `GetSecretValuesAsync_MultipleSecrets_ReturnsAllRequested` | Credentials, `AKEYLESS_SECRET_STATIC_TEXT` + `AKEYLESS_SECRET_STATIC_TEXT_2` | +| `GetSecretValuesAsync_InvalidToken_ThrowsApiException` | Credentials | + +#### `AkeylessPamIntegrationTests` — exercises the full `AkeylessPam.GetPassword()` stack + +| Test | Requires | +|---|---| +| `GetPassword_StaticText_ReturnsNonEmptyValue` | Credentials, `AKEYLESS_SECRET_STATIC_TEXT` | +| `GetPassword_StaticKv_UsernameField_ReturnsValue` | Credentials, `AKEYLESS_SECRET_STATIC_KV` | +| `GetPassword_StaticKv_PasswordField_ReturnsValue` | Credentials, `AKEYLESS_SECRET_STATIC_KV` | +| `GetPassword_StaticJson_UsernameField_ReturnsValue` | Credentials, `AKEYLESS_SECRET_STATIC_JSON` | +| `GetPassword_StaticJson_PasswordField_ReturnsValue` | Credentials, `AKEYLESS_SECRET_STATIC_JSON` | +| `GetPassword_StaticJson_NoFieldName_ReturnsRawJsonBlob` | Credentials, `AKEYLESS_SECRET_STATIC_JSON_RAW` | +| `GetPassword_BadCredentials_ThrowsInvalidClientConfigurationException` | `AKEYLESS_SECRET_STATIC_TEXT` (uses deliberately wrong credentials) | +| `GetPassword_NonexistentSecret_ThrowsException` | Credentials (uses hardcoded nonexistent path) | diff --git a/global.json b/global.json index 90b3042..a27a2b8 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "version": "9.0.0", - "rollForward": "latestFeature", + "rollForward": "latestMajor", "allowPrerelease": false } } \ No newline at end of file diff --git a/integration-manifest.json b/integration-manifest.json index 7329288..119c13d 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -1,11 +1,11 @@ { - "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", + "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", "integration_type": "pam", "name": "Akeyless PAM Provider", "status": "production", "support_level": "kf-supported", - "link_github": false, - "update_catalog": false, + "link_github": true, + "update_catalog": true, "release_dir": "akeyless-pam/bin/Release", "release_project": "akeyless-pam/akeyless-pam.csproj", "description": "The Akeyless PAM Provider allows for the retrieval of stored account credentials from an Akeyless secret.", @@ -21,14 +21,14 @@ "Parameters": [ { "Name": "Url", - "DisplayName": "Secret Server URL", - "Description": "The URL to the Secret Server instance. Example: https://example.cloud.com/", + "DisplayName": "Akeyless URL", + "Description": "The URL to the Akeyless instance. Defaults to: https://api.akeyless.io", "DataType": 1, "InstanceLevel": false }, { - "Name": "AccessKeyId", - "DisplayName": "Access Key ID", + "Name": "AccessId", + "DisplayName": "Access ID", "Description": "The access key ID used to authenticate to Akeyless using `access_key` authentication.", "DataType": 2, "InstanceLevel": false @@ -71,7 +71,6 @@ ] } ] - } } } } diff --git a/tests/AkeylessPam.Integration.Tests/AkeylessApiClientTests.cs b/tests/AkeylessPam.Integration.Tests/AkeylessApiClientTests.cs new file mode 100644 index 0000000..f1db4cc --- /dev/null +++ b/tests/AkeylessPam.Integration.Tests/AkeylessApiClientTests.cs @@ -0,0 +1,164 @@ +// 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 akeyless.Client; +using Keyfactor.Extensions.Pam.Akeyless; +using Xunit; + +namespace Keyfactor.Tests.Integration; + +/// +/// Integration tests for exercising the real Akeyless API. +/// Credentials are loaded from environment variables or a .env file in the repo root. +/// +/// Secret paths used by retrieval tests default to the same paths as TestConsole and can be +/// overridden with environment variables: +/// AKEYLESS_SECRET_STATIC_TEXT (default: pam/test/pamStaticTextUsername) +/// AKEYLESS_SECRET_STATIC_KV (default: pam/test/pamStaticKV) +/// AKEYLESS_SECRET_STATIC_JSON (default: pam/test/pamStaticJSON) +/// +public class AkeylessApiClientTests +{ + static AkeylessApiClientTests() => DotEnvLoader.Load(); + + private static string AccessId => + Environment.GetEnvironmentVariable("AKEYLESS_ACCESS_ID") ?? string.Empty; + + private static string AccessKey => + Environment.GetEnvironmentVariable("AKEYLESS_ACCESS_KEY") ?? string.Empty; + + private static string ApiUrl + { + get + { + var url = Environment.GetEnvironmentVariable("AKEYLESS_API_URL"); + return string.IsNullOrEmpty(url) ? "https://api.akeyless.io" : url; + } + } + + private static AkeylessApiClient Client => new(ApiUrl); + + private static void SkipIfMissingCredentials() + { + Skip.If(string.IsNullOrEmpty(AccessId) || string.IsNullOrEmpty(AccessKey), + "AKEYLESS_ACCESS_ID and AKEYLESS_ACCESS_KEY not set; skipping API client integration tests."); + } + + private static string RequireSecretPath(string envVar, string defaultPath) + { + var val = Environment.GetEnvironmentVariable(envVar); + return string.IsNullOrEmpty(val) ? defaultPath : val; + } + + // ── Authentication ────────────────────────────────────────────────────── + + [SkippableFact] + public void Authenticate_ValidCredentials_ReturnsNonEmptyToken() + { + SkipIfMissingCredentials(); + + var token = Client.Authenticate(AccessId, AccessKey); + + Assert.NotNull(token); + Assert.NotEmpty(token); + } + + [SkippableFact] + public void Authenticate_InvalidCredentials_ThrowsApiException() + { + SkipIfMissingCredentials(); + + Assert.Throws(() => + Client.Authenticate("p-bad-id", "bad-key")); + } + + // ── Secret retrieval ───────────────────────────────────────────────────── + + [SkippableFact] + public async Task GetSecretValuesAsync_StaticTextSecret_ReturnsDictWithValue() + { + SkipIfMissingCredentials(); + var secretName = RequireSecretPath("AKEYLESS_SECRET_STATIC_TEXT", "pam/test/pamStaticTextUsername"); + + var client = Client; + var token = client.Authenticate(AccessId, AccessKey); + var result = await client.GetSecretValuesAsync([secretName], token); + + Assert.True(result.ContainsKey(secretName), $"Response did not contain key '{secretName}'"); + Assert.NotEmpty(result[secretName]); + } + + [SkippableFact] + public async Task GetSecretValuesAsync_StaticKvSecret_ReturnsDictWithValue() + { + SkipIfMissingCredentials(); + var secretName = RequireSecretPath("AKEYLESS_SECRET_STATIC_KV", "pam/test/pamStaticKV"); + + var client = Client; + var token = client.Authenticate(AccessId, AccessKey); + var result = await client.GetSecretValuesAsync([secretName], token); + + Assert.True(result.ContainsKey(secretName), $"Response did not contain key '{secretName}'"); + Assert.NotEmpty(result[secretName]); + } + + [SkippableFact] + public async Task GetSecretValuesAsync_StaticJsonSecret_ReturnsDictWithValue() + { + SkipIfMissingCredentials(); + var secretName = RequireSecretPath("AKEYLESS_SECRET_STATIC_JSON", "pam/test/pamStaticJSON"); + + var client = Client; + var token = client.Authenticate(AccessId, AccessKey); + var result = await client.GetSecretValuesAsync([secretName], token); + + Assert.True(result.ContainsKey(secretName), $"Response did not contain key '{secretName}'"); + Assert.NotEmpty(result[secretName]); + } + + [SkippableFact] + public async Task GetSecretValuesAsync_MultipleSecrets_ReturnsAllRequested() + { + SkipIfMissingCredentials(); + var secret1 = RequireSecretPath("AKEYLESS_SECRET_STATIC_TEXT", "pam/test/pamStaticTextUsername"); + var secret2 = RequireSecretPath("AKEYLESS_SECRET_STATIC_TEXT_2", "pam/test/pamStaticTextPassword"); + + var client = Client; + var token = client.Authenticate(AccessId, AccessKey); + var result = await client.GetSecretValuesAsync([secret1, secret2], token); + + Assert.True(result.ContainsKey(secret1), $"Response did not contain key '{secret1}'"); + Assert.True(result.ContainsKey(secret2), $"Response did not contain key '{secret2}'"); + } + + [SkippableFact] + public async Task GetSecretValuesAsync_InvalidToken_ThrowsApiException() + { + SkipIfMissingCredentials(); + + var client = Client; + await Assert.ThrowsAsync(() => + client.GetSecretValuesAsync(["pam/test/any"], "invalid-token")); + } + + // ── Debug ───────────────────────────────────────────────────────────────── + + [SkippableFact] + public async Task Debug_K8sOrchestratorSecret_PrintsRawValue() + { + SkipIfMissingCredentials(); + const string secretName = "/pam/test/k8s-orchestrator"; + + var client = Client; + var token = client.Authenticate(AccessId, AccessKey); + var result = await client.GetSecretValuesAsync([secretName], token); + + // NOTE: secret value is intentionally not written to Console/output to prevent secret exposure in CI logs. + + Assert.True(result.ContainsKey(secretName), $"Response did not contain key '{secretName}'"); + } +} diff --git a/tests/AkeylessPam.Integration.Tests/AkeylessPam.Integration.Tests.csproj b/tests/AkeylessPam.Integration.Tests/AkeylessPam.Integration.Tests.csproj new file mode 100644 index 0000000..bc95379 --- /dev/null +++ b/tests/AkeylessPam.Integration.Tests/AkeylessPam.Integration.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + false + enable + enable + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/AkeylessPam.Integration.Tests/AkeylessPamIntegrationTests.cs b/tests/AkeylessPam.Integration.Tests/AkeylessPamIntegrationTests.cs new file mode 100644 index 0000000..bad91ab --- /dev/null +++ b/tests/AkeylessPam.Integration.Tests/AkeylessPamIntegrationTests.cs @@ -0,0 +1,256 @@ +// 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 Keyfactor.Extensions.Pam.Akeyless; +using Xunit; + +namespace Keyfactor.Tests.Integration; + +/// +/// Integration tests that connect to a live Akeyless instance. +/// All tests are skipped when the required environment variables are not set. +/// +/// +/// Required env vars for all tests: +/// AKEYLESS_ACCESS_ID — Akeyless access key ID +/// AKEYLESS_ACCESS_KEY — Akeyless access key secret +/// +/// Optional env vars: +/// AKEYLESS_API_URL — Akeyless API URL (defaults to https://api.akeyless.io) +/// AKEYLESS_AUTH_TYPE — Auth type (defaults to access_key) +/// +/// Per-test secret path env vars: +/// AKEYLESS_SECRET_STATIC_TEXT — path to a static_text secret +/// AKEYLESS_SECRET_STATIC_KV — path to a static_kv secret with "username" and "password" fields +/// AKEYLESS_SECRET_STATIC_JSON — path to a static_json secret with "username" and "password" fields +/// AKEYLESS_SECRET_STATIC_JSON_RAW — path to a static_json secret to retrieve as a raw blob +/// +public class AkeylessPamIntegrationTests +{ + private static Dictionary BuildServerParams() + { + return new Dictionary + { + ["Url"] = Env("AKEYLESS_API_URL", "https://api.akeyless.io"), + ["AuthType"] = Env("AKEYLESS_AUTH_TYPE", "access_key"), + ["AccessId"] = Env("AKEYLESS_ACCESS_ID"), + ["AccessKey"] = Env("AKEYLESS_ACCESS_KEY") + }; + } + + private static string Env(string key, string? fallback = null) + => Environment.GetEnvironmentVariable(key) ?? fallback ?? string.Empty; + + private static void SkipIfMissingCredentials() + { + var id = Environment.GetEnvironmentVariable("AKEYLESS_ACCESS_ID"); + var key = Environment.GetEnvironmentVariable("AKEYLESS_ACCESS_KEY"); + Skip.If(string.IsNullOrEmpty(id) || string.IsNullOrEmpty(key), + "AKEYLESS_ACCESS_ID and AKEYLESS_ACCESS_KEY env vars not set; skipping integration tests."); + } + + private static void SkipIfMissingSecretPath(string envVar) + { + Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envVar)), + $"{envVar} env var not set; skipping this integration test."); + } + + [SkippableFact] + public void GetPassword_StaticText_ReturnsNonEmptyValue() + { + SkipIfMissingCredentials(); + SkipIfMissingSecretPath("AKEYLESS_SECRET_STATIC_TEXT"); + + var pam = new AkeylessPam(); + var instance = new Dictionary + { + ["SecretType"] = "static_text", + ["SecretName"] = Env("AKEYLESS_SECRET_STATIC_TEXT") + }; + + var result = pam.GetPassword(instance, BuildServerParams()); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + [SkippableFact] + public void GetPassword_StaticKv_UsernameField_ReturnsValue() + { + SkipIfMissingCredentials(); + SkipIfMissingSecretPath("AKEYLESS_SECRET_STATIC_KV"); + + var pam = new AkeylessPam(); + var instance = new Dictionary + { + ["SecretType"] = "static_kv", + ["SecretName"] = Env("AKEYLESS_SECRET_STATIC_KV"), + ["StaticSecretFieldName"] = "username" + }; + + var result = pam.GetPassword(instance, BuildServerParams()); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + [SkippableFact] + public void GetPassword_StaticKv_PasswordField_ReturnsValue() + { + SkipIfMissingCredentials(); + SkipIfMissingSecretPath("AKEYLESS_SECRET_STATIC_KV"); + + var pam = new AkeylessPam(); + var instance = new Dictionary + { + ["SecretType"] = "static_kv", + ["SecretName"] = Env("AKEYLESS_SECRET_STATIC_KV"), + ["StaticSecretFieldName"] = "password" + }; + + var result = pam.GetPassword(instance, BuildServerParams()); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + [SkippableFact] + public void GetPassword_StaticJson_UsernameField_ReturnsValue() + { + SkipIfMissingCredentials(); + SkipIfMissingSecretPath("AKEYLESS_SECRET_STATIC_JSON"); + + var pam = new AkeylessPam(); + var instance = new Dictionary + { + ["SecretType"] = "static_json", + ["SecretName"] = Env("AKEYLESS_SECRET_STATIC_JSON"), + ["StaticSecretFieldName"] = "username" + }; + + var result = pam.GetPassword(instance, BuildServerParams()); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + [SkippableFact] + public void GetPassword_StaticJson_PasswordField_ReturnsValue() + { + SkipIfMissingCredentials(); + SkipIfMissingSecretPath("AKEYLESS_SECRET_STATIC_JSON"); + + var pam = new AkeylessPam(); + var instance = new Dictionary + { + ["SecretType"] = "static_json", + ["SecretName"] = Env("AKEYLESS_SECRET_STATIC_JSON"), + ["StaticSecretFieldName"] = "password" + }; + + var result = pam.GetPassword(instance, BuildServerParams()); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + [SkippableFact] + public void GetPassword_StaticJson_NoFieldName_ReturnsRawJsonBlob() + { + SkipIfMissingCredentials(); + SkipIfMissingSecretPath("AKEYLESS_SECRET_STATIC_JSON_RAW"); + + var pam = new AkeylessPam(); + var instance = new Dictionary + { + ["SecretType"] = "static_json", + ["SecretName"] = Env("AKEYLESS_SECRET_STATIC_JSON_RAW") + // no StaticSecretFieldName — expect full JSON blob back + }; + + var result = pam.GetPassword(instance, BuildServerParams()); + + Assert.NotNull(result); + Assert.NotEmpty(result); + // NOTE: result value is intentionally excluded from the assertion message to prevent secret exposure. + Assert.True(result.TrimStart().StartsWith('{') || result.TrimStart().StartsWith('['), + "Expected raw JSON blob but result did not start with '{' or '['"); + } + + [SkippableFact] + public void GetPassword_BadCredentials_ThrowsInvalidClientConfigurationException() + { + SkipIfMissingSecretPath("AKEYLESS_SECRET_STATIC_TEXT"); // need a valid secret path + + var server = new Dictionary + { + ["Url"] = Env("AKEYLESS_API_URL", "https://api.akeyless.io"), + ["AuthType"] = "access_key", + ["AccessId"] = "p-bad-id", + ["AccessKey"] = "bad-key" + }; + var instance = new Dictionary + { + ["SecretType"] = "static_text", + ["SecretName"] = Env("AKEYLESS_SECRET_STATIC_TEXT") + }; + + var pam = new AkeylessPam(); + var ex = Assert.Throws(() => pam.GetPassword(instance, server)); + Assert.IsType(ex.InnerException); + } + + [SkippableFact] + public void GetPassword_NonexistentSecret_ThrowsException() + { + SkipIfMissingCredentials(); + + var pam = new AkeylessPam(); + var instance = new Dictionary + { + ["SecretType"] = "static_text", + ["SecretName"] = "/pam/does/not/exist/at/all" + }; + + // Akeyless returns an ApiException (404-like) for nonexistent secrets rather than + // an empty response, so it propagates as-is. Both domain exceptions and ApiException + // are acceptable here until the adapter normalizes SDK errors into domain exceptions. + Assert.ThrowsAny(() => pam.GetPassword(instance, BuildServerParams())); + } + + [SkippableFact] + public void GetPassword_StaticJson_NoFieldName_ReturnsRawJsonBlob_K8sOrchestratorSecret() + { + SkipIfMissingCredentials(); + const string secretName = "/pam/test/k8s-orchestrator"; + + var pam = new AkeylessPam(); + var result = pam.GetPassword( + new Dictionary { ["SecretType"] = "static_json", ["SecretName"] = secretName }, + BuildServerParams()); + + Assert.NotEmpty(result); + Assert.True(result.TrimStart().StartsWith('{') || result.TrimStart().StartsWith('['), + "Expected raw JSON blob"); + } + + [SkippableFact] + public void GetPassword_StaticJson_WhitespaceFieldName_ReturnsRawJsonBlob_K8sOrchestratorSecret() + { + SkipIfMissingCredentials(); + const string secretName = "/pam/test/k8s-orchestrator"; + + var pam = new AkeylessPam(); + var result = pam.GetPassword( + new Dictionary { ["SecretType"] = "static_json", ["SecretName"] = secretName, ["StaticSecretFieldName"] = " " }, + BuildServerParams()); + + Assert.NotEmpty(result); + Assert.True(result.TrimStart().StartsWith('{') || result.TrimStart().StartsWith('['), + "Expected raw JSON blob even when StaticSecretFieldName is whitespace-only"); + } +} diff --git a/tests/AkeylessPam.Integration.Tests/DotEnvLoader.cs b/tests/AkeylessPam.Integration.Tests/DotEnvLoader.cs new file mode 100644 index 0000000..1ad2fbf --- /dev/null +++ b/tests/AkeylessPam.Integration.Tests/DotEnvLoader.cs @@ -0,0 +1,49 @@ +// 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. + +namespace Keyfactor.Tests.Integration; + +/// +/// Loads variables from a .env file into the process environment, without overwriting +/// variables that are already set. Walks up the directory tree from the executing assembly +/// to find the repo-root .env file. +/// +internal static class DotEnvLoader +{ + internal static void Load() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, ".env"); + if (File.Exists(candidate)) + { + foreach (var line in File.ReadAllLines(candidate)) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue; + + // Strip optional leading "export " + if (trimmed.StartsWith("export ", StringComparison.OrdinalIgnoreCase)) + trimmed = trimmed["export ".Length..].TrimStart(); + + var eq = trimmed.IndexOf('='); + if (eq <= 0) continue; + + var key = trimmed[..eq].Trim(); + var value = trimmed[(eq + 1)..].Trim().Trim('"'); + + // Only set if not already present in the environment + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(key))) + Environment.SetEnvironmentVariable(key, value); + } + return; + } + dir = dir.Parent; + } + } +} diff --git a/tests/AkeylessPam.Integration.Tests/README.md b/tests/AkeylessPam.Integration.Tests/README.md new file mode 100644 index 0000000..c4ace5d --- /dev/null +++ b/tests/AkeylessPam.Integration.Tests/README.md @@ -0,0 +1,64 @@ +# AkeylessPam.Integration.Tests + +Integration tests for the Akeyless PAM Provider that connect to a live Akeyless instance. All tests are marked `[SkippableFact]` and skip automatically when the required environment variables are not set, making them safe to run in CI without credentials configured. + +## Running + +```shell +# Via Makefile +make test-integration + +# Or directly +dotnet test tests/AkeylessPam.Integration.Tests/ +``` + +Credentials can be provided via environment variables or a `.env` file in the repo root. The `.env` file is loaded automatically by the test setup and does not override variables already set in the environment (safe for CI use). + +### Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `AKEYLESS_ACCESS_ID` | Yes | — | Akeyless Access ID (API key) | +| `AKEYLESS_ACCESS_KEY` | Yes | — | Akeyless Access Key secret | +| `AKEYLESS_API_URL` | No | `https://api.akeyless.io` | Akeyless API endpoint | +| `AKEYLESS_AUTH_TYPE` | No | `access_key` | Auth type passed to the provider | +| `AKEYLESS_SECRET_STATIC_TEXT` | Per-test | `pam/test/pamStaticTextUsername` | Path to a `static_text` secret | +| `AKEYLESS_SECRET_STATIC_TEXT_2` | Per-test | `pam/test/pamStaticTextPassword` | Path to a second `static_text` secret (used in multi-secret test) | +| `AKEYLESS_SECRET_STATIC_KV` | Per-test | `pam/test/pamStaticKV` | Path to a `static_kv` secret with `username` and `password` fields | +| `AKEYLESS_SECRET_STATIC_JSON` | Per-test | `pam/test/pamStaticJSON` | Path to a `static_json` secret with `username` and `password` fields | +| `AKEYLESS_SECRET_STATIC_JSON_RAW` | Per-test | — | Path to a `static_json` secret to retrieve as a raw blob (no field extraction) | + +## Test Files + +### `AkeylessPamIntegrationTests.cs` + +End-to-end tests for `AkeylessPam.GetPassword()` against a live Akeyless instance. Each test constructs server and instance parameter dictionaries the same way Keyfactor Command would, then calls the provider. + +| Test | Requires | Description | +|------|----------|-------------| +| `GetPassword_StaticText_ReturnsNonEmptyValue` | credentials + `AKEYLESS_SECRET_STATIC_TEXT` | Retrieves a `static_text` secret and asserts a non-empty value is returned | +| `GetPassword_StaticKv_UsernameField_ReturnsValue` | credentials + `AKEYLESS_SECRET_STATIC_KV` | Retrieves the `username` field from a `static_kv` secret | +| `GetPassword_StaticKv_PasswordField_ReturnsValue` | credentials + `AKEYLESS_SECRET_STATIC_KV` | Retrieves the `password` field from a `static_kv` secret | +| `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_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 | + +--- + +### `AkeylessApiClientTests.cs` + +Lower-level tests for `AkeylessApiClient` — the adapter that wraps the Akeyless SDK `V2Api`. These tests exercise authentication and secret retrieval directly without going through the PAM provider layer. + +| Test | Description | +|------|-------------| +| `Authenticate_ValidCredentials_ReturnsNonEmptyToken` | Authenticates with valid credentials and asserts a non-empty API token is returned | +| `Authenticate_InvalidCredentials_ThrowsApiException` | Authenticates with bad credentials and asserts an `ApiException` is thrown | +| `GetSecretValuesAsync_StaticTextSecret_ReturnsDictWithValue` | Retrieves a `static_text` secret and asserts the response dictionary contains the secret path as a key with a non-empty value | +| `GetSecretValuesAsync_StaticKvSecret_ReturnsDictWithValue` | Retrieves a `static_kv` secret and asserts the response dictionary contains a non-empty value | +| `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 | diff --git a/tests/AkeylessPam.Unit.Tests/AkeylessConfigurationTests.cs b/tests/AkeylessPam.Unit.Tests/AkeylessConfigurationTests.cs new file mode 100644 index 0000000..ad8e0b5 --- /dev/null +++ b/tests/AkeylessPam.Unit.Tests/AkeylessConfigurationTests.cs @@ -0,0 +1,168 @@ +// 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 System.ComponentModel.DataAnnotations; +using Keyfactor.Extensions.Pam.Akeyless; +using Keyfactor.Extensions.Pam.Akeyless.Models; +using Xunit; + +namespace Keyfactor.Tests.Unit; + +public class AkeylessConfigurationTests +{ + private static IList Validate(AkeylessConfiguration config) + { + var ctx = new ValidationContext(config); + var results = new List(); + Validator.TryValidateObject(config, ctx, results, validateAllProperties: true); + // Also invoke IValidatableObject.Validate explicitly since TryValidateObject may not call it + results.AddRange(config.Validate(ctx)); + return results; + } + + [Fact] + public void Validate_ValidAccessKeyConfig_NoErrors() + { + var config = new AkeylessConfiguration + { + AccessId = "p-abc123", + AccessKey = "super-secret", + SecretType = "static_text", + SecretName = "pam/test/secret", + AuthType = "access_key" + }; + + var errors = Validate(config); + + Assert.Empty(errors); + } + + [Fact] + public void Validate_MissingAccessId_ReturnsError() + { + var config = new AkeylessConfiguration + { + AccessId = "", + AccessKey = "super-secret", + SecretType = "static_text", + SecretName = "pam/test/secret", + AuthType = "access_key" + }; + + var errors = Validate(config); + + Assert.Contains(errors, e => e.MemberNames.Contains(nameof(AkeylessConfiguration.AccessId))); + } + + [Fact] + public void Validate_MissingAccessKey_ReturnsError() + { + var config = new AkeylessConfiguration + { + AccessId = "p-abc123", + AccessKey = "", + SecretType = "static_text", + SecretName = "pam/test/secret", + AuthType = "access_key" + }; + + var errors = Validate(config); + + Assert.Contains(errors, e => e.MemberNames.Contains(nameof(AkeylessConfiguration.AccessKey))); + } + + [Fact] + public void Validate_UnsupportedAuthType_ReturnsError() + { + var config = new AkeylessConfiguration + { + AccessId = "p-abc123", + AccessKey = "super-secret", + SecretType = "static_text", + SecretName = "pam/test/secret", + AuthType = "saml" // unsupported + }; + + var errors = Validate(config); + + Assert.NotEmpty(errors); + } + + [Fact] + public void Validate_UnsupportedSecretType_ReturnsError() + { + var config = new AkeylessConfiguration + { + AccessId = "p-abc123", + AccessKey = "super-secret", + SecretType = "dynamic_secret", + SecretName = "pam/test/secret", + AuthType = "access_key" + }; + + var errors = Validate(config); + + Assert.Contains(errors, e => e.MemberNames.Contains(nameof(AkeylessConfiguration.SecretType))); + } + + [Fact] + public void Validate_StaticKvMissingFieldName_ReturnsError() + { + var config = new AkeylessConfiguration + { + AccessId = "p-abc123", + AccessKey = "super-secret", + SecretType = "static_kv", + SecretName = "pam/test/secret", + StaticSecretFieldName = "", + AuthType = "access_key" + }; + + var errors = Validate(config); + + Assert.Contains(errors, e => e.MemberNames.Contains(nameof(AkeylessConfiguration.StaticSecretFieldName))); + } + + [Fact] + public void Validate_StaticJsonMissingFieldName_NoError() + { + // StaticSecretFieldName is optional for static_json (returns full blob when omitted) + var config = new AkeylessConfiguration + { + AccessId = "p-abc123", + AccessKey = "super-secret", + SecretType = "static_json", + SecretName = "pam/test/secret", + StaticSecretFieldName = "", + AuthType = "access_key" + }; + + var errors = Validate(config); + + Assert.DoesNotContain(errors, e => e.MemberNames.Contains(nameof(AkeylessConfiguration.StaticSecretFieldName))); + } + + [Fact] + public void SupportedSecretTypes_ContainsExpectedValues() + { + Assert.Contains("static_text", AkeylessConfiguration.SupportedSecretTypes); + Assert.Contains("static_kv", AkeylessConfiguration.SupportedSecretTypes); + Assert.Contains("static_json", AkeylessConfiguration.SupportedSecretTypes); + } + + [Fact] + public void Constants_DefaultAuthMethod_IsAccessKey() + { + Assert.Equal("access_key", AkeylessConstants.DefaultAuthMethod); + } + + [Fact] + public void Constants_DefaultApiUrl_IsCorrect() + { + Assert.Equal("https://api.akeyless.io", AkeylessConstants.DefaultAkeylessApiUrl); + } +} diff --git a/tests/AkeylessPam.Unit.Tests/AkeylessPam.Unit.Tests.csproj b/tests/AkeylessPam.Unit.Tests/AkeylessPam.Unit.Tests.csproj new file mode 100644 index 0000000..aa5a286 --- /dev/null +++ b/tests/AkeylessPam.Unit.Tests/AkeylessPam.Unit.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + false + enable + enable + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/AkeylessPam.Unit.Tests/AkeylessPamTests.cs b/tests/AkeylessPam.Unit.Tests/AkeylessPamTests.cs new file mode 100644 index 0000000..9baf37b --- /dev/null +++ b/tests/AkeylessPam.Unit.Tests/AkeylessPamTests.cs @@ -0,0 +1,308 @@ +// 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 Keyfactor.Extensions.Pam.Akeyless; +using Moq; +using Xunit; + +namespace Keyfactor.Tests.Unit; + +/// +/// Helper to build the standard valid server config dictionary. +/// +internal static class Params +{ + internal static Dictionary ValidServer( + string authType = "access_key", + string accessId = "test-id", + string accessKey = "test-key", + string url = "https://api.akeyless.io") => new() + { + ["AuthType"] = authType, + ["AccessId"] = accessId, + ["AccessKey"] = accessKey, + ["Url"] = url + }; + + internal static Dictionary Instance( + string secretName = "pam/test/secret", + string secretType = "static_text", + string? fieldName = null) + { + var d = new Dictionary + { + ["SecretName"] = secretName, + ["SecretType"] = secretType + }; + if (fieldName != null) d["StaticSecretFieldName"] = fieldName; + return d; + } +} + +public class ValidationTests +{ + [Fact] + public void GetPassword_MissingSecretName_ThrowsInvalidClientConfigurationException() + { + var pam = new AkeylessPam(_ => Mock.Of()); + var instance = new Dictionary { ["SecretType"] = "static_text" }; // no SecretName + + Assert.Throws(() => + pam.GetPassword(instance, Params.ValidServer())); + } + + [Fact] + public void GetPassword_MissingAccessId_ThrowsInvalidClientConfigurationException() + { + var pam = new AkeylessPam(_ => Mock.Of()); + var server = new Dictionary + { + ["AuthType"] = "access_key", + ["AccessKey"] = "test-key" + // no AccessId + }; + + Assert.Throws(() => + pam.GetPassword(Params.Instance(), server)); + } + + [Fact] + public void GetPassword_MissingAccessKey_ThrowsInvalidClientConfigurationException() + { + var pam = new AkeylessPam(_ => Mock.Of()); + var server = new Dictionary + { + ["AuthType"] = "access_key", + ["AccessId"] = "test-id" + // no AccessKey + }; + + Assert.Throws(() => + pam.GetPassword(Params.Instance(), server)); + } + + [Fact] + public void GetPassword_InvalidAuthType_Throws() + { + var pam = new AkeylessPam(_ => Mock.Of()); + var server = new Dictionary + { + ["AuthType"] = "unsupported_auth", + ["AccessId"] = "test-id", + ["AccessKey"] = "test-key" + }; + + Assert.Throws(() => + pam.GetPassword(Params.Instance(), server)); + } + + [Fact] + public void GetPassword_InvalidSecretType_ThrowsInvalidClientConfigurationException() + { + var pam = new AkeylessPam(_ => Mock.Of()); + var instance = Params.Instance(secretType: "dynamic_secret"); + + Assert.Throws(() => + pam.GetPassword(instance, Params.ValidServer())); + } +} + +public class AuthenticationTests +{ + [Fact] + public void GetPassword_AuthenticateReturnsEmptyToken_ThrowsInvalidTokenException() + { + var mock = new Mock(); + mock.Setup(c => c.Authenticate(It.IsAny(), It.IsAny())) + .Returns(string.Empty); + + 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() + { + 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://custom.akeyless.io")); + + Assert.Equal("https://custom.akeyless.io", capturedBasePath); + } +} + +public class SecretRetrievalTests +{ + private static AkeylessPam PamWithMockReturning(string secretName, string secretValue) + { + 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 { [secretName] = secretValue }); + return new AkeylessPam(_ => mock.Object); + } + + [Fact] + public void GetPassword_StaticText_PlainString_ReturnsAsIs() + { + var pam = PamWithMockReturning("pam/test/secret", "my-password"); + + var result = pam.GetPassword(Params.Instance(), Params.ValidServer()); + + Assert.Equal("my-password", result); + } + + [Fact] + public void GetPassword_StaticText_JsonContent_ReturnsFullJsonBlob() + { + const string json = "{\"username\":\"admin\",\"password\":\"s3cr3t\"}"; + var pam = PamWithMockReturning("pam/test/secret", json); + + var result = pam.GetPassword(Params.Instance(secretType: "static_text"), Params.ValidServer()); + + Assert.Equal(json, result); + } + + [Fact] + public void GetPassword_StaticKv_ReturnsMatchingFieldValue() + { + const string kvContent = "username=admin\npassword=s3cr3t\n"; + var pam = PamWithMockReturning("pam/test/secret", kvContent); + + var result = pam.GetPassword( + Params.Instance(secretType: "static_kv", fieldName: "password"), + Params.ValidServer()); + + Assert.Equal("s3cr3t", result); + } + + [Fact] + public void GetPassword_StaticKv_MissingField_ThrowsInvalidSecretConfigurationException() + { + const string kvContent = "username=admin\npassword=s3cr3t\n"; + var pam = PamWithMockReturning("pam/test/secret", kvContent); + + var ex = Assert.Throws(() => + pam.GetPassword( + Params.Instance(secretType: "static_kv", fieldName: "nonexistent"), + Params.ValidServer())); + + Assert.IsType(ex.InnerException); + } + + [Fact] + public void GetPassword_StaticJson_ReturnsSpecifiedField() + { + const string json = "{\"username\":\"admin\",\"password\":\"s3cr3t\"}"; + var pam = PamWithMockReturning("pam/test/secret", json); + + var result = pam.GetPassword( + Params.Instance(secretType: "static_json", fieldName: "username"), + Params.ValidServer()); + + Assert.Equal("admin", result); + } + + [Fact] + public void GetPassword_StaticJson_NoFieldName_ReturnsFullBlob() + { + const string json = "{\"username\":\"admin\",\"password\":\"s3cr3t\"}"; + var pam = PamWithMockReturning("pam/test/secret", json); + + var result = pam.GetPassword( + Params.Instance(secretType: "static_json"), // no fieldName + Params.ValidServer()); + + Assert.Equal(json, result); + } + + [Fact] + public void GetPassword_StaticJson_MissingField_ThrowsInvalidSecretConfigurationException() + { + const string json = "{\"username\":\"admin\"}"; + var pam = PamWithMockReturning("pam/test/secret", json); + + var ex = Assert.Throws(() => + pam.GetPassword( + Params.Instance(secretType: "static_json", fieldName: "nonexistent"), + Params.ValidServer())); + + Assert.IsType(ex.InnerException); + } + + [Fact] + public void GetPassword_SecretNotInResponse_ThrowsInvalidSecretConfigurationException() + { + 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()); // empty — secret not found + + var pam = new AkeylessPam(_ => mock.Object); + + var ex = Assert.Throws(() => + pam.GetPassword(Params.Instance(), Params.ValidServer())); + + Assert.IsType(ex.InnerException); + } + + [Fact] + public void GetPassword_EmptySecretValue_ThrowsInvalidSecretConfigurationException() + { + var pam = PamWithMockReturning("pam/test/secret", string.Empty); + + var ex = Assert.Throws(() => + pam.GetPassword(Params.Instance(), Params.ValidServer())); + + Assert.IsType(ex.InnerException); + } + + [Fact] + public void GetPassword_StaticJson_WhitespaceFieldName_ReturnsFullBlob() + { + // Command UI may send whitespace instead of empty string — should be treated as no field name + const string json = "{\"username\":\"admin\",\"password\":\"s3cr3t\"}"; + var pam = PamWithMockReturning("pam/test/secret", json); + + var result = pam.GetPassword( + Params.Instance(secretType: "static_json", fieldName: " "), + Params.ValidServer()); + + Assert.Equal(json, result); + } + + [Fact] + public void GetPassword_StaticKv_JsonStoredAsKv_ParsesViaJson() + { + // When SecretType is static_kv but content is JSON, ParseJsonSecret is used + const string json = "{\"username\":\"admin\",\"password\":\"s3cr3t\"}"; + var pam = PamWithMockReturning("pam/test/secret", json); + + var result = pam.GetPassword( + Params.Instance(secretType: "static_kv", fieldName: "password"), + Params.ValidServer()); + + Assert.Equal("s3cr3t", result); + } +} diff --git a/tests/AkeylessPam.Unit.Tests/README.md b/tests/AkeylessPam.Unit.Tests/README.md new file mode 100644 index 0000000..74f41a1 --- /dev/null +++ b/tests/AkeylessPam.Unit.Tests/README.md @@ -0,0 +1,73 @@ +# AkeylessPam.Unit.Tests + +Unit tests for the Akeyless PAM Provider. Tests run entirely in-process with no external dependencies — the Akeyless API is replaced by a Moq mock of `IAkeylessApiClient`. + +## Running + +```shell +# Via Makefile +make test-unit + +# Or directly +dotnet test tests/AkeylessPam.Unit.Tests/ +``` + +No environment variables or credentials required. + +## Test Files + +### `AkeylessPamTests.cs` + +Tests for `AkeylessPam.GetPassword()` covering configuration validation, authentication behavior, and secret parsing logic. The Akeyless API client is mocked so all tests are deterministic and offline. + +#### ValidationTests + +| Test | Description | +|------|-------------| +| `GetPassword_MissingSecretName_ThrowsInvalidClientConfigurationException` | Throws when `SecretName` is absent from instance parameters | +| `GetPassword_MissingAccessId_ThrowsInvalidClientConfigurationException` | Throws when `AccessId` is absent from server parameters | +| `GetPassword_MissingAccessKey_ThrowsInvalidClientConfigurationException` | Throws when `AccessKey` is absent from server parameters | +| `GetPassword_InvalidAuthType_Throws` | Throws when `AuthType` is not a recognized value | +| `GetPassword_InvalidSecretType_ThrowsInvalidClientConfigurationException` | Throws when `SecretType` is not one of `static_text`, `static_json`, `static_kv` | + +#### AuthenticationTests + +| Test | Description | +|------|-------------| +| `GetPassword_AuthenticateReturnsEmptyToken_ThrowsInvalidTokenException` | When the API client returns an empty token, throws `InvalidTokenException` wrapped in `AggregateException` | +| `GetPassword_UsesConfiguredUrl_WhenNoEnvVar` | The URL passed to the API client factory matches the `Url` server parameter | + +#### SecretRetrievalTests + +| Test | Description | +|------|-------------| +| `GetPassword_StaticText_PlainString_ReturnsAsIs` | A plain-text `static_text` secret is returned unchanged | +| `GetPassword_StaticText_JsonContent_ReturnsFullJsonBlob` | A JSON-formatted value stored as `static_text` is returned as the full JSON string | +| `GetPassword_StaticKv_ReturnsMatchingFieldValue` | A `static_kv` secret returns the value of the field named by `StaticSecretFieldName` | +| `GetPassword_StaticKv_MissingField_ThrowsInvalidSecretConfigurationException` | Throws `InvalidSecretConfigurationException` when `StaticSecretFieldName` does not exist in the KV secret | +| `GetPassword_StaticJson_ReturnsSpecifiedField` | A `static_json` secret returns the value of the field named by `StaticSecretFieldName` | +| `GetPassword_StaticJson_NoFieldName_ReturnsFullBlob` | A `static_json` secret without `StaticSecretFieldName` returns the full JSON blob | +| `GetPassword_StaticJson_MissingField_ThrowsInvalidSecretConfigurationException` | Throws `InvalidSecretConfigurationException` when `StaticSecretFieldName` is not present in the JSON | +| `GetPassword_SecretNotInResponse_ThrowsInvalidSecretConfigurationException` | Throws `InvalidSecretConfigurationException` when the API returns an empty dictionary (secret not found) | +| `GetPassword_EmptySecretValue_ThrowsInvalidSecretConfigurationException` | Throws `InvalidSecretConfigurationException` when the API returns an empty string for the secret value | +| `GetPassword_StaticJson_WhitespaceFieldName_ReturnsFullBlob` | A `static_json` secret with a whitespace-only `StaticSecretFieldName` (e.g. a space from the Command UI) returns the full JSON blob | +| `GetPassword_StaticKv_JsonStoredAsKv_ParsesViaJson` | When a `static_kv` secret contains JSON instead of `key=value` lines, falls back to JSON parsing | + +--- + +### `AkeylessConfigurationTests.cs` + +Tests for `AkeylessConfiguration` — validating configuration model constraints, supported type lists, and constant values. + +| Test | Description | +|------|-------------| +| `Validate_ValidAccessKeyConfig_NoErrors` | A fully populated `access_key` configuration produces no validation errors | +| `Validate_MissingAccessId_ReturnsError` | Empty `AccessId` produces a validation error referencing the `AccessId` field | +| `Validate_MissingAccessKey_ReturnsError` | Empty `AccessKey` produces a validation error referencing the `AccessKey` field | +| `Validate_UnsupportedAuthType_ReturnsError` | An unsupported `AuthType` (e.g. `saml`) produces a validation error | +| `Validate_UnsupportedSecretType_ReturnsError` | An unsupported `SecretType` (e.g. `dynamic_secret`) produces a validation error referencing the `SecretType` field | +| `Validate_StaticKvMissingFieldName_ReturnsError` | Empty `StaticSecretFieldName` with `SecretType = static_kv` produces a validation error | +| `Validate_StaticJsonMissingFieldName_NoError` | Empty `StaticSecretFieldName` with `SecretType = static_json` produces no validation error (field is optional for JSON) | +| `SupportedSecretTypes_ContainsExpectedValues` | `AkeylessConfiguration.SupportedSecretTypes` contains `static_text`, `static_kv`, and `static_json` | +| `Constants_DefaultAuthMethod_IsAccessKey` | `AkeylessConstants.DefaultAuthMethod` and `DefaultAuthMethodReadOnly` are both `access_key` | +| `Constants_DefaultApiUrl_IsCorrect` | `AkeylessConstants.DefaultAkeylessApiUrl` is `https://api.akeyless.io` |