From 2e0cd7a54aeb582789b4cb3860553145f9ac3bc8 Mon Sep 17 00:00:00 2001 From: Mark Kachkaev Date: Mon, 26 Oct 2026 12:53:37 -0400 Subject: [PATCH 1/3] Added ACME Cert support. Fixed numerous bugs - see changelog. --- .gitignore | 13 +- CHANGELOG.md | 27 +- README.md | 773 +++++++++++++++--- .../Jobs/AuthDescriptionTests.cs | 103 +++ .../Jobs/DiscoveryTests.cs | 286 +++++++ .../Jobs/FilterParityTests.cs | 151 ++++ .../Jobs/InventoryTests.cs | 326 ++++++++ .../Jobs/ManagementTests.cs | 337 ++++++++ .../Jobs/StorePathParserTests.cs | 120 +++ .../TestUtilities/AcmMocks.cs | 93 +++ .../TestUtilities/CertificateFactory.cs | 138 ++++ .../TestUtilities/TestableJobs.cs | 75 ++ .../TestUtilities/XunitLogger.cs | 67 ++ .../aws-acm-orchestrator.Tests.csproj | 22 +- aws-acm-orchestrator.sln | 29 +- aws-acm-orchestrator/AssemblyInfo.cs | 24 + aws-acm-orchestrator/Jobs/Discovery.cs | 46 +- aws-acm-orchestrator/Jobs/Inventory.cs | 89 +- aws-acm-orchestrator/Jobs/Management.cs | 25 +- .../aws-acm-orchestrator.csproj | 56 +- docsource/aws-acm-v3.md | 87 +- docsource/content.md | 626 ++++++++++++-- integration-manifest.json | 2 +- .../bash/curl_create_store_types.sh | 2 +- .../restmethod_create_store_types.ps1 | 2 +- 25 files changed, 3232 insertions(+), 287 deletions(-) create mode 100644 aws-acm-orchestrator.Tests/Jobs/AuthDescriptionTests.cs create mode 100644 aws-acm-orchestrator.Tests/Jobs/DiscoveryTests.cs create mode 100644 aws-acm-orchestrator.Tests/Jobs/FilterParityTests.cs create mode 100644 aws-acm-orchestrator.Tests/Jobs/InventoryTests.cs create mode 100644 aws-acm-orchestrator.Tests/Jobs/ManagementTests.cs create mode 100644 aws-acm-orchestrator.Tests/Jobs/StorePathParserTests.cs create mode 100644 aws-acm-orchestrator.Tests/TestUtilities/AcmMocks.cs create mode 100644 aws-acm-orchestrator.Tests/TestUtilities/CertificateFactory.cs create mode 100644 aws-acm-orchestrator.Tests/TestUtilities/TestableJobs.cs create mode 100644 aws-acm-orchestrator.Tests/TestUtilities/XunitLogger.cs create mode 100644 aws-acm-orchestrator/AssemblyInfo.cs diff --git a/.gitignore b/.gitignore index 0ab729a..26082df 100644 --- a/.gitignore +++ b/.gitignore @@ -348,6 +348,17 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ -/.claude/settings.local.json + +# Claude Code local configuration, hooks, memory, and session artifacts. +# Note: CLAUDE.md is intentionally NOT ignored - it is shared project guidance. +.claude/ +CLAUDE.local.md +.mcp.json + +# Local credentials - never commit +/creds.json + /Feature-Spec-Cross-Account-Discovery-1.pdf /Ticket.txt +/CLAUDE.md +/TEST-ENVIRONMENT.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a14f87c..05a138d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,34 @@ +3.1.1 +* Bug Fix - **Inventory** and **Discovery** now return ACME-issued certificates + * `ListCertificates` omits certificates with a `CertificateKeyPairOrigin` of `ACME` unless the origins are requested explicitly, so ACME-issued certificates were silently absent from inventory. Both jobs now request all origins the AWS SDK defines (`AWS_MANAGED`, `CUSTOMER_PROVIDED`, `ACME`) + * Previously a region containing only ACME-issued certificates appeared empty to Discovery and no certificate store was created for it + * ACME-issued certificates are inventoried for visibility only. Like Amazon-issued and Private CA-issued certificates they are not user-imported, so Management renewal/replace still fails by design - the failure message now names the certificate's `Type` and key pair origin instead of only "Amazon-issued or Private CA-issued" +* Bug Fix - Added `RSA_3072` to the Inventory key type filter. `Includes.KeyTypes` is an inclusion filter, so RSA-3072 certificates were silently absent from inventory +* Bug Fix - **Inventory** no longer reports every entry as a private key entry + * `PrivateKeyEntry` was hard-coded to `true`, so Command offered renewal/replace on certificates ACM holds no private key for - Amazon-issued, Private CA-issued, and ACME-issued - and the Management job then failed at run time by design + * `PrivateKeyEntry` is now derived from the certificate's `Type` and is set only for user-imported (`IMPORTED`) certificates. `Type` is returned by `ListCertificates`, so this adds no API calls +* Bug Fix - **Inventory** no longer depends on the PEM it receives ending in a newline + * The PEM footer was only stripped when a newline followed it, so a certificate body returned without a trailing newline would have been sent to Command with literal `-----END CERTIFICATE-----` text inside the Base64 value. ACM always sends the newline, so this never occurred in practice +* Bug Fix - **Management** alias-to-ARN matching is now case-insensitive throughout + * The `arn:aws:acm:` prefix test was case-insensitive but the `:certificate/` resource test was not, so a fully uppercased ARN was not recognised as an existing certificate and would have been imported as a new certificate instead of replaced in place, leaving a duplicate in ACM. ACM returns lowercase ARNs, so this only affected a hand-entered alias +* Bug Fix - The **Discovery** region probe now applies the same `Includes.KeyTypes` filter as **Inventory** + * The probe requested the ACME key pair origins but no key types, and `Includes.KeyTypes` is an inclusion filter, so a region whose certificates all lacked a SAN looked empty to Discovery and no certificate store was created for it - the same defect as the ACME-origin bug above, in the other filter +* Security - **Management** no longer writes the certificate contents to the log + * `JobCertificate.Contents` (the base64 PFX, which carries the private key) was logged at Trace level on every Add job. Only its length is logged now +* Inventory now requests 1000 certificates per `ListCertificates` page instead of 100 (1000 is the documented maximum), reducing round trips on large stores +* Inventory no longer serializes ACM responses to JSON for Trace logging when Trace logging is disabled. The per-certificate dumps ran on every certificate regardless of log level +* Removed a redundant unfiltered `ListCertificates` call in Inventory that fed only a log line +* Added Debug logging of the filters applied by Inventory, the key pair origins and certificate types found per batch, and the filter set when a region returns no certificates +* Documentation rewrite - `docsource/content.md` and `docsource/aws-acm-v3.md` now cover the destination-identity model explicitly, give exact field values per authentication method, and include copy/adapt IAM and trust policy examples for inventory-only, inventory + management, and cross-account Discovery + * Added sections on cross-account Discovery job configuration (dialog field mapping and region scanning), certificate types and renewal eligibility, ACM tag behaviour on renewal, `ListCertificates` inclusion-filter behaviour, and troubleshooting +* Bug Fix - Corrected the `OAuthUrl` store type property description in `integration-manifest.json`, which had been copy-pasted from `ExternalId` and described an `sts:ExternalId` parameter instead of the OAuth token endpoint URL + 3.1.0 * Added support for cross-account **Discovery** * Discovery enumerates AWS Organizations accounts via `organizations:ListAccounts`, assumes a per-account IAM role (configurable via the new `DiscoveryRoleName` property; default `KeyfactorACMDiscoveryRole`), and scans each account's enabled regions for ACM certificates * Discovered certificate stores use a self-contained `Store Path` of the form `|` that carries both the Role ARN to assume and the AWS Region in a single field; legacy region-only `Store Path` values (with the Role ARN in `Client Machine`) continue to work * Added the `DiscoveryRoleName` store type property -* Discovery, Inventory, and Management jobs now return descriptive success, warning, and failure messages to Keyfactor Command that identify the exact store (and, for Discovery, the accounts and regions scanned) instead of directing the user to the logs . +* Discovery, Inventory, and Management jobs now return descriptive success, warning, and failure messages to Keyfactor Command that identify the exact store (and, for Discovery, the accounts and regions scanned) instead of directing the user to the logs 3.0.2 * Bug Fix - On Management jobs, do not send ACM tags if the certificate is being renewed/replaced diff --git a/README.md b/README.md index 120a170..a029eca 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@

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

@@ -31,32 +31,64 @@ ## Overview -> [!WARNING] -> -> **The `AWS-ACM-v3` store type identifies each certificate store by a single AWS `Region` in the `Store Path` field, with the full Role ARN in `Client Machine`.** +The AWS Certificate Manager (ACM) Universal Orchestrator extension manages SSL/TLS certificates +held in [AWS Certificate Manager](https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html) +from Keyfactor Command. It supports **Inventory**, **Management** (add / renew / remove) and +**cross-account Discovery**. + +Each certificate store instance maps to **exactly one AWS region in exactly one AWS account**. +Certificate aliases within a store are full ACM certificate ARNs, for example +`arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012`. + +Authentication is handled by the shared [aws-auth-library](https://github.com/Keyfactor/aws-auth-library), +so the extension supports the AWS Default SDK credential chain (including EC2 instance roles and +named credential profiles), IAM User access keys, and OAuth 2.0 / OIDC federation against a third-party +identity provider. The Default SDK and IAM User methods can optionally finish by performing an +**Assume Role** into the account that actually holds the certificates; the OAuth method always +assumes a role, via `sts:AssumeRoleWithWebIdentity`. + +> [!IMPORTANT] +> **`Store Path` accepts two formats.** > -> **New with cross-account Discovery:** `Store Path` now also accepts a **self-contained** value of the form **`|`** — for example `arn:aws:iam::123456789012:role/KeyfactorACMDiscoveryRole|us-east-1` — which carries **both** the Role ARN to assume **and** the Region in a single field. Certificate stores created by cross-account Discovery use this combined format, and for those stores the `Client Machine` field is informational only. Legacy region-only `Store Path` values (with the Role ARN in `Client Machine`) continue to work. +> | Format | Example | Where the Role ARN comes from | +> |---|---|---| +> | Region only (legacy) | `us-east-1` | the `Client Machine` field | +> | `\|` (self-contained) | `arn:aws:iam::123456789012:role/KeyfactorACMDiscoveryRole\|us-east-1` | the `Store Path` itself; `Client Machine` is informational only | > -> **Migrating from `AWS-ACM`, `AwsCerManO`, or `AwsCerManA`:** those older store types are **not** compatible and must be recreated as `AWS-ACM-v3`. - -AWS Certificate Manager is a service that lets you easily provision, manage, and deploy public and private Secure Sockets Layer/Transport Layer Security (SSL/TLS) -certificates for use with AWS services and your internal connected resources. -SSL/TLS certificates are used to secure network communications and establish the identity of websites over the Internet as well as resources on private networks. -AWS Certificate Manager removes the time-consuming manual process of purchasing, uploading, and renewing SSL/TLS certificates. -The orchestrator supports OAuth OIDC authentication, as well as AWS IAM accounts, and various options provided by the AWS SDK such as EC2 instance credentials. -The OAuth OIDC support allows authentication against a 3rd party identity provider in AWS. -After initial authentication, temporary credentials are used by using the Assume Role functionality in AWS. - -This integration also supports the reading of existing certificate ACM key/value pair tags during inventory and adding these tags when adding new certificates. -Modifying and adding ACM tags during certificate renewal, however, is NOT supported. -This is because the AWS API does not allow for ACM tag modification when updating a certificate in one step. -This would need to be done in multiple steps, leading to the possibility of the certificate being left in an error state if any intermediate step were to fail. -However, while the modification/addition of ACM tags is not supported, all existing ACM tags WILL remain in place during renewal. - +> Stores created by cross-account Discovery always use the self-contained form. Both formats are +> supported indefinitely — existing region-only stores do not need to be changed. + +> [!WARNING] +> **Migrating from `AWS-ACM`, `AwsCerManO`, or `AwsCerManA`:** those store types are **not** +> compatible with `AWS-ACM-v3` and must be recreated. See +> [Migrating from an older ACM store type](#migrating-from-an-older-acm-store-type). + +### What this extension does and does not do + +| Capability | Supported | Notes | +|---|---|---| +| Inventory certificates | ✅ | Includes certificates in `ISSUED`, `INACTIVE`, `EXPIRED` and `REVOKED` states | +| Add (import) a certificate | ✅ | PFX is split into certificate, private key and chain, and re-encoded as PEM for `ImportCertificate` | +| Renew / replace a certificate | ✅ | **Only for certificates whose ACM `Type` is `IMPORTED`** | +| Remove a certificate | ✅ | **Only for certificates whose ACM `Type` is `IMPORTED`** | +| Cross-account Discovery | ✅ | Enumerates accounts via AWS Organizations and probes each region | +| Read existing ACM tags | ✅ | Surfaced on the `ACM Tags` entry parameter during Inventory | +| Set ACM tags on a **new** import | ✅ | `Name1=Value1,Name2=Value2` format | +| Change ACM tags on **renewal** | ❌ | AWS does not accept tags when reimporting a certificate; see [ACM tags](#acm-tags) | +| Reenrollment / on-device key generation | ❌ | ACM does not expose a CSR-generation API for imported certificates | + +> [!NOTE] +> **Amazon-issued, Private CA and ACME certificates are inventory-only.** Their ACM `Type` is not +> `IMPORTED`, so ACM rejects any attempt to replace or delete them through this API path. The +> Inventory job marks them `PrivateKeyEntry = false` so Command does not offer a renewal that is +> guaranteed to fail. See [Certificate types and renewal](#certificate-types-and-renewal). + ### Documentation -- [How AWS works in this extension (aws-auth-library)](https://github.com/Keyfactor/aws-auth-library) -- [AWS Region Codes](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html) +- [How AWS authentication works in this extension (aws-auth-library)](https://github.com/Keyfactor/aws-auth-library) +- [AWS Region codes](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html) +- [AWS Certificate Manager user guide](https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html) +- [ACM service quotas and API request rates](https://docs.aws.amazon.com/acm/latest/userguide/acm-limits.html) ## Compatibility @@ -72,120 +104,629 @@ The AWS Certificate Manager (ACM) Universal Orchestrator extension is supported Before installing the AWS Certificate Manager (ACM) Universal Orchestrator extension, we recommend that you install [kfutil](https://github.com/Keyfactor/kfutil). Kfutil is a command-line tool that simplifies the process of creating store types, installing extensions, and instantiating certificate stores in Keyfactor Command. -### Migrate existing ACM stores to the new type (AWS Certificate Manager v3) +### Migrating from an older ACM store type + +Field usage changed in v3 and does **not** map across directly: + +| Older store types (`AWS-ACM`, `AwsCerManO`, `AwsCerManA`) | `AWS-ACM-v3` | +|---|---| +| `ServerUsername` / `ServerPassword` carried credentials | No longer used. Dedicated IAM and OAuth fields replace them | +| AWS Region supplied as an Entry Parameter | Region is part of `Store Path`; the Entry Parameter is gone | +| `Client Machine` held a host or short name | `Client Machine` holds the **full Role ARN** used for Assume Role calls | +| One store could span regions | One store = **one region**; define a separate store per region | + +Migration steps: + +1. Create the `AWS-ACM-v3` store type (once per Command instance). +2. Recreate each certificate store against the new type. +3. Run an Inventory job on each new store to begin tracking those certificates again. +4. Delete the deprecated stores and store types once nothing depends on them. + +There is no automated migration utility. + +### Key concept — the destination identity + +> **Whichever identity the extension ends up operating as — the assumed Role ARN, or the +> *originating* identity when no Assume Role is performed — is the *destination identity*, and it +> is what must hold the ACM permissions** (see +> [Example IAM policies](#example-iam-policies-for-the-destination-identity)). +> +> For every Assume-Role method the **originating** identity additionally needs `sts:AssumeRole` on +> the target role, and the target role's **trust policy** must allow that originating principal +> (see [Example trust policies](#example-trust-policies)). + +Getting this distinction wrong is the most common cause of `AccessDenied` from a store that looks +correctly configured. Work out which identity is the destination *first*, then attach the ACM +permissions there. + +### Common requirements (all methods) + +- **Region** — supplied through `Store Path`, either as the whole value (`us-east-1`) or as the + part after the pipe (`|us-east-1`). Must be the region the certificates live in. +- **Client Machine** — the full Role ARN to assume, e.g. + `arn:aws:iam::123456789012:role/Keyfactor-ACM-Role`. Prefix with `[profile-name]` to select a + named credential profile under Default SDK auth. Informational only for stores that use the + self-contained `|` store path. +- **ACM permissions on the destination identity** — see + [Example IAM policies](#example-iam-policies-for-the-destination-identity). +- **One store per region per account** — additional regions need their own stores. + +> [!WARNING] +> When creating certificate stores, **every `Secret`-type field must be given a value**, even if +> that value is "No Value". The secret fields on this store type are `OAuthClientId`, +> `OAuthClientSecret`, `IAMUserAccessKey` and `IAMUserAccessSecret`. Leaving an unused secret field +> empty causes errors that may require database access to correct. + +### Method configuration -Field usage has changed in v3, notably: -* `ServerUsername` and `ServerPassword` are no longer used - * Specific fields for IAM and OAuth are defined for credentials of those types -* `Store Path` only allows a __single__ AWS Region to be defined - * The Entry Parameter for AWS Region is no longer used -* `Client Machine` requires the _full_ Role ARN to be used for Assume Role calls +Choose **one** method per certificate store and set the auth toggles you are **not** using to +`false`. -As a result, previous Store Types are no longer supported, and Certificate Stores of those types need to be migrated to the v3 type. -Inventory jobs will need to be to run after creating the new Certificate Stores to begin tracking those certificates again. -The deprecated Stores and Store Types can be deleted after they are no longer needed. +#### Default SDK (no assume) -_Currently, there is no provided migration utility to perform this programmatically._ +- `UseDefaultSdkAuth` = `true`, `DefaultSdkAssumeRole` = `false` +- `Client Machine` is ignored for credential resolution + +The orchestrator host's **ambient AWS identity** is the destination and must hold the ACM +permissions directly. The AWS SDK resolves it in the usual order: `AWS_*` environment variables, +the `[default]` profile in the shared credentials file, then the EC2 instance role. + +#### Default SDK + Assume Role + +- `UseDefaultSdkAuth` = `true`, `DefaultSdkAssumeRole` = `true` +- `Client Machine` = destination Role ARN + +The ambient identity is the **originating** account and performs `sts:AssumeRole` into the +`Client Machine` role. The ambient identity needs `sts:AssumeRole`; the destination role's trust +policy must allow it and the destination role holds the ACM permissions. + +> This is the recommended Default SDK configuration. Without the assume flag it is easy to lose +> track of which identity is actually reaching ACM. + +#### Credential Profile (± Assume Role) + +Prefix `Client Machine` with a profile name, e.g. +`[myprofile]arn:aws:iam::123456789012:role/Keyfactor-ACM-Role`, and set `UseDefaultSdkAuth` = `true`. +The named profile must exist in the shared AWS credentials file **on the orchestrator host**. + +- **No assume** (`DefaultSdkAssumeRole` = `false`): the profile identity is the destination and needs + the ACM permissions directly. +- **With assume** (`DefaultSdkAssumeRole` = `true`): the profile is the originating identity and + assumes the Role ARN that follows the prefix. + +#### IAM User (Access Key + Secret) + +- `UseIAM` = `true` +- `IAMUserAccessKey` (e.g. `AKIAIOSFODNN7EXAMPLE`) +- `IAMUserAccessSecret` (e.g. `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) +- `Client Machine` = destination Role ARN to assume + +**AWS setup:** the IAM user needs `sts:AssumeRole` on the destination role; that role's trust policy +must allow the IAM user (see [Assume-Role trust](#assume-role--iam-user-default-sdk--assume-or-credential-profile--assume)); +the role holds the [ACM permissions](#example-iam-policies-for-the-destination-identity). + +> AWS does not support programmatic access for AWS SSO accounts. This must be a standard IAM User +> with an Access Key credential type. + +#### IAM User + ExternalId + +As **IAM User**, plus set `ExternalId`. The destination role's trust policy must include a matching +`sts:ExternalId` condition (see [Assume Role with ExternalId](#assume-role-with-externalid)). Use +this when the role owner requires a shared secret to prevent the +[confused-deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). + +#### OAuth 2.0 (OIDC federation) + +- `UseOAuth` = `true` +- `OAuthClientId`, `OAuthClientSecret` +- `OAuthUrl` — the identity provider's **token endpoint**, e.g. + `https://your-tenant.okta.com/oauth2/default/v1/token` +- `OAuthScope`, `OAuthGrantType` (`client_credentials`) +- `Client Machine` = destination Role ARN (a federated web-identity role) + +The extension requests an OAuth token and calls `sts:AssumeRoleWithWebIdentity` on the destination +role. + +**AWS setup:** create an IAM **OIDC identity provider** for the token issuer, and set the destination +role's trust policy to allow `sts:AssumeRoleWithWebIdentity` for that provider with the appropriate +`aud` (and optionally `sub`) conditions — see [OAuth web-identity trust](#oauth--web-identity-trust). + +> [!NOTE] +> OAuth provider constraints: the client id/secret must be accepted as an HTTP **Basic** auth header +> (not a POST body); **DPoP must be disabled** on the app; and the token lifetime must be +> **≤ 12 hours**, because STS caps `AssumeRoleWithWebIdentity` `DurationSeconds` at 43200 and the +> session duration is derived from the token's `expires_in`. ### Setting up AWS Authentication (Examples) > [!NOTE] -> Several different options are offered for authenticating with AWS. -> Documentation for how these options work is now located in the [aws-auth-library](https://github.com/Keyfactor/aws-auth-library) repository. +> Several different options are offered for authenticating with AWS. Documentation for how these +> options work is maintained in the [aws-auth-library](https://github.com/Keyfactor/aws-auth-library) +> repository. -The following examples show potential configurations for Roles in AWS with different selected authentication methods. -Your configuration steps may differ depending on specific requirements of your use case. +The following examples show potential configurations for Roles in AWS with different selected +authentication methods. Your configuration steps may differ depending on the specific requirements +of your use case. In every case the **destination identity** must hold the ACM permissions, and +every Assume-Role method additionally requires the originating identity to have `sts:AssumeRole` and +the target role's trust policy to allow that principal.

EC2 instance credentials using Default SDK and Assume Role Select the `Use Default SDK Auth` option to allow the integration to load EC2 instance credentials. -If the EC2 Role assigned to the instance is intended as the Destination account identity to use with ACM, no additional Role needs to be configured. -If the EC2 Role assigned to the instance is only to be used initially, and a new Role ARN is designated as the Destination account in the `Client Machine` field, -then the `Assume new Role using Default SDK Auth` should also be selected. +If the EC2 Role assigned to the instance is itself the destination identity to use with ACM, no +additional Role needs to be configured. If it is only to be used initially, and a separate Role ARN +is designated as the destination in the `Client Machine` field, also select +`Assume new Role using Default SDK Auth`. -### AWS Setup -_Note: In this scenario, the AWS-ACM-v3 extension needs to be running inside an EC2 instance._ -1. Assign or note the existing IAM Role assigned to the EC2 instance running. [Found in EC2 here](docsource/images/ec2-instance-iam-role.gif). -2. Ensure a [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role. [Example](docsource/images/ec2-role-arn-trust-relationship.gif). -3. Verify the permissions match the requirements for accessing ACM. +**AWS Setup** -
+_Note: in this scenario the extension must be running inside an EC2 instance._ + +1. Assign or note the existing IAM Role assigned to the EC2 instance. + [Found in EC2 here](docsource/images/ec2-instance-iam-role.gif). +2. If Assume Role is used, ensure a + [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) + on the destination role allows the instance role to assume it. + [Example](docsource/images/ec2-role-arn-trust-relationship.gif). +3. Verify the destination role's permissions match + [the ACM requirements](#example-iam-policies-for-the-destination-identity). +
OAuth OIDC Identity Provider (Okta example) Select the `Use OAuth` option for a certificate store to use an OAuth Identity Provider. -### AWS Setup -1. A 3rd party [Identity Provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) similar to [this](docsource/images/AWSIdentityProvider.gif) needs to be set up in AWS. -2. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) needs to be created to be used with your Identity Provider. -3. Ensure the [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role with the Identity Provider. [Example](docsource/images/AWSRole2.gif). -4. Verify the permissions match the requirements for accessing ACM. - -### OKTA Setup -1. Ensure your Authorization Server Is Set up in OKTA. Here is a [sample](docsource/images/OktaSampleAuthorizationServer.gif). -2. Ensure the appropriate scopes are set up in Okta. Here is a [sample](docsource/images/OktaSampleAuthorizationServer-scopes.gif). -3. Set up an Okta App with similar settings to [this](docsource/images/OktaApp1.gif) and [this](docsource/images/OktaApp2.gif). +**AWS Setup** + +1. A third-party + [Identity Provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) + similar to [this](docsource/images/AWSIdentityProvider.gif) needs to be set up in AWS. +2. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) + needs to be created for use with your Identity Provider. + [Example](docsource/images/AWSRole1.gif). +3. Ensure the + [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) + is set up for that role with the Identity Provider, allowing `sts:AssumeRoleWithWebIdentity` with + the appropriate `aud` (and optionally `sub`) conditions. + [Example](docsource/images/AWSRole2.gif), and see + [OAuth web-identity trust](#oauth--web-identity-trust). +4. Verify the permissions match + [the ACM requirements](#example-iam-policies-for-the-destination-identity). + +**Okta Setup** + +1. Ensure your Authorization Server is set up in Okta. Here is a + [sample](docsource/images/OktaSampleAuthorizationServer.gif). +2. Ensure the appropriate scopes are set up in Okta. Here is a + [sample](docsource/images/OktaSampleAuthorizationServer-scopes.gif). +3. Set up an Okta App with similar settings to [this](docsource/images/OktaApp1.gif) and + [this](docsource/images/OktaApp2.gif). The app must send the client id/secret as a **Basic auth + header**, have **DPoP disabled**, and issue tokens with a lifetime **≤ 12 hours**.
-
IAM User credentials to Assume Role Select the `Use IAM` option for a certificate store to use an IAM User credential. -### AWS Setup -1. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) to Assume with your IAM User needs to be created. -2. Ensure a [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role. [Example](docsource/images/AssumeRoleTrust.gif). -3. AWS does not support programmatic access for AWS SSO accounts. The account used here must be a [standard AWS IAM User](docsource/images/UserAccount.gif) with an Access Key credential type. -4. Verify the permissions match the requirements for accessing ACM. +**AWS Setup** + +1. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) to + assume with your IAM User needs to be created, and set as `Client Machine`. +2. Ensure a + [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) + is set up for that role that allows the IAM User. + [Example](docsource/images/AssumeRoleTrust.gif). If using the ExternalId modifier, add a matching + `sts:ExternalId` condition. +3. AWS does not support programmatic access for AWS SSO accounts. The account used here must be a + [standard AWS IAM User](docsource/images/UserAccount.gif) with an Access Key credential type. +4. Verify the permissions match + [the ACM requirements](#example-iam-policies-for-the-destination-identity).
+--- + +### Example IAM policies for the destination identity + +These permissions belong on the **destination identity** (the assumed Role ARN, or the +ambient/profile identity when no Assume Role is performed). The following examples are intended as +**copy/adapt templates** — scope `Resource` down to specific certificate ARNs where your policy +standards require it. + +#### Example 1: Inventory only (read-only) + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AcmInventory", + "Effect": "Allow", + "Action": [ + "acm:ListCertificates", + "acm:GetCertificate", + "acm:ListTagsForCertificate" + ], + "Resource": "*" + } + ] +} +``` + +`acm:ListCertificates` does not operate on a single resource and must be granted on `*`. The other +two actions can be scoped to `arn:aws:acm:::certificate/*`. + +#### Example 2: Inventory + Management (add, renew, remove) + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AcmList", + "Effect": "Allow", + "Action": "acm:ListCertificates", + "Resource": "*" + }, + { + "Sid": "AcmInventoryAndManage", + "Effect": "Allow", + "Action": [ + "acm:GetCertificate", + "acm:ListTagsForCertificate", + "acm:DescribeCertificate", + "acm:ImportCertificate", + "acm:DeleteCertificate" + ], + "Resource": "arn:aws:acm:::certificate/*" + }, + { + "Sid": "AcmTagOnImport", + "Effect": "Allow", + "Action": "acm:AddTagsToCertificate", + "Resource": "arn:aws:acm:::certificate/*" + } + ] +} +``` + +> [!NOTE] +> `acm:ImportCertificate` covers both new imports and renewals — a renewal is an `ImportCertificate` +> call that supplies the existing `CertificateArn`. +> +> Tags supplied on an import are governed by `acm:ImportCertificate` itself: AWS documents the +> `aws:RequestTag/${TagKey}` and `aws:TagKeys` condition keys on that action and lists no dependent +> action for it. The `AcmTagOnImport` statement above is therefore belt-and-braces — include it if +> your policy standards prefer the tagging action to be explicit, or if the same role also needs to +> tag certificates outside of an import. Omit it otherwise. + +#### Example 3: Cross-account Discovery + +Discovery uses two different identities. The **management identity** enumerates the organization: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "EnumerateOrgAccounts", + "Effect": "Allow", + "Action": "organizations:ListAccounts", + "Resource": "*" + }, + { + "Sid": "AssumeDiscoveryRoleInMembers", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::*:role/KeyfactorACMDiscoveryRole" + } + ] +} +``` + +The **per-account discovery role** (`KeyfactorACMDiscoveryRole` by default) needs only enough to +answer "does this region hold any certificates?": + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ProbeRegionsForCertificates", + "Effect": "Allow", + "Action": [ + "acm:ListCertificates", + "ec2:DescribeRegions" + ], + "Resource": "*" + } + ] +} +``` + +`ec2:DescribeRegions` is only used when the region list is left blank or set to `*`, to narrow the +scan to the regions each account actually has enabled. It can be omitted if you always supply an +explicit region list. See [Cross-account Discovery](#cross-account-discovery). + +--- + +### Example trust policies + +Every Assume-Role method requires a **trust policy** on the destination role that allows the +originating principal to assume it. (Default SDK *no-assume* and Credential Profile *no-assume* need +no trust policy — the identity is used directly.) + +#### Assume Role — IAM User, Default SDK + Assume, or Credential Profile + Assume + +`<>` is the IAM user ARN (IAM User method) or the ambient/profile identity +ARN (Default SDK / Credential Profile + Assume). For an EC2 instance role this is the role ARN, not +the instance profile ARN. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +#### Assume Role with ExternalId + +Add a matching `sts:ExternalId` condition; the store's `ExternalId` field must equal it. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole", + "Condition": { "StringEquals": { "sts:ExternalId": "<>" } } + } + ] +} +``` + +#### OAuth — web-identity trust + +The destination role for the OAuth method must trust the OIDC provider you registered for your token +issuer. Condition on the token `aud` (audience) and optionally `sub` — for `client_credentials`, +`sub` is typically the client id. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "Federated": "arn:aws:iam:::oidc-provider//" }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "/:aud": "<>", + "/:sub": "<>" + } + } + } + ] +} +``` + +#### Cross-account Discovery role trust + +Each member account's `KeyfactorACMDiscoveryRole` must trust the management identity that performs +the assume. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +--- + +### Cross-account Discovery + +A Discovery job walks an AWS Organization and reports back one certificate store per +account-and-region that actually holds certificates. Discovered stores use the self-contained +`|` store path. + +**How it resolves accounts and regions** + +1. Base credentials are resolved from the job's settings, then used to call + `organizations:ListAccounts`. +2. For each active account, the job assumes `arn:aws:iam:::role/`. +3. For each candidate region it calls `acm:ListCertificates`; a region with at least one certificate + becomes a discovered store. + +If `organizations:ListAccounts` is denied, the job logs a warning and **falls back to +single-account discovery** using the base credentials — it does not fail. + +**Discovery dialog field mapping** + +The standard Command Discovery dialog has no AWS-specific fields, so settings are carried on the +generic dialog fields: + +| Dialog field | Setting | Default / meaning | +|---|---|---| +| **Directories to search** | Management-account Role ARN to assume | Falls back to `Client Machine` if blank | +| **Directories to ignore** | Account IDs to skip | Comma-separated | +| **File name patterns to match** | `DiscoveryRoleName` — the IAM role name assumed in each member account | `KeyfactorACMDiscoveryRole` | +| **File extensions** | Regions to scan, comma-separated | Blank or `*` = every region in the base region's partition | + +> [!NOTE] +> Because the dialog carries no auth-method fields, an operator-launched Discovery job arrives with +> all auth toggles `false`. The job then **defaults to Default SDK auth**, and enables Assume Role +> automatically when "Directories to search" contains something that looks like an IAM role ARN +> (`arn:aws:iam:`, `arn:aws-us-gov:iam:` or `arn:aws-cn:iam:`). + +**Region scanning behaviour** + +- **Explicit list** (e.g. `us-east-1,us-east-2`) — used verbatim, no auto-detection. +- **Blank or `*`** — every region in the base region's partition, narrowed per account by + `ec2:DescribeRegions` to the regions that account has actually enabled. + +There is no hard-coded list of "common" regions. Scanning every region in a large organization is +one `ListCertificates` call per account per region, so prefer an explicit list when you know where +your certificates are. + +--- + +### Operational behaviour and limitations + +#### Certificate types and renewal + +ACM reports a `Type` for every certificate. Only `IMPORTED` certificates can be replaced or deleted +through this extension: + +| ACM `Type` | Origin | Inventory | Renew / Remove | +|---|---|---|---| +| `IMPORTED` | Uploaded by you or by Command | ✅ | ✅ | +| `AMAZON_ISSUED` | Issued by ACM's public CA | ✅ | ❌ | +| `PRIVATE` | Issued by an AWS Private CA | ✅ | ❌ | +| *(ACME origin)* | Issued through AWS's public ACME endpoint | ✅ | ❌ | + +The Inventory job sets `PrivateKeyEntry` from this `Type`, so Command only offers renewal on +certificates where it can actually succeed. + +> [!NOTE] +> **ACME-issued certificates have no subject DN.** AWS's public ACME CA issues SAN-only certificates +> with an empty subject, and ACM itself reports `Subject: ""`. Command derives `IssuedDN` / `IssuedCN` +> by parsing the uploaded certificate, so these appear with a blank common name and are findable +> only by SAN. This is a property of the certificates, not something the extension can correct. + +#### ACM tags + +The `ACM Tags` entry parameter uses comma-separated `Name1=Value1,Name2=Value2` pairs. + +- **On a new import**, tags are applied with the certificate. +- **On a renewal**, tags are **not** applied. This is an AWS constraint, not an extension choice: + the `ImportCertificate` API documents that tags cannot be applied when *reimporting* a certificate. + Applying them would take additional calls that could leave the certificate in an inconsistent + state if one of them failed, so the extension skips them. +- **Existing tags survive a renewal** — they are simply left alone. +- Inventory reads existing tags back onto the entry parameter. + +#### Inventory request behaviour + +ACM's `ListCertificates` filters are **inclusion** filters: anything not explicitly named is absent +from the result, with no error. The extension therefore requests every key algorithm and every key +pair origin that the AWS SDK defines, which is what makes SAN-less and ACME-origin certificates +visible. If a future AWS release adds a new algorithm or origin, certificates using it will be +silently missing until the extension is updated. + +Inventory makes two additional API calls per certificate (`GetCertificate` and +`ListTagsForCertificate`). ACM documents a request rate of roughly 10/second for each, so a store +with several thousand certificates will take a corresponding amount of time; this is an ACM +constraint rather than an extension one. + +#### Troubleshooting + +| Symptom | Likely cause | +|---|---| +| `AccessDenied` from a seemingly correct store | ACM permissions are on the originating identity instead of the [destination identity](#key-concept--the-destination-identity) | +| Assume Role fails | Destination role's [trust policy](#example-trust-policies) does not allow the originating principal, or `ExternalId` does not match | +| Store errors that need database access to fix | A `Secret`-type field was left empty — set unused secret fields to "No Value" | +| Discovery finds no stores | `organizations:ListAccounts` denied (check the log for the fallback warning), or the per-account role name does not match `DiscoveryRoleName` | +| Renewal fails on a specific certificate | The certificate's ACM `Type` is not `IMPORTED` — see [Certificate types and renewal](#certificate-types-and-renewal) | +| OAuth token rejected by STS | Token lifetime exceeds 12 hours, DPoP is enabled, or the provider requires credentials in the POST body rather than a Basic header | + ## AWS-ACM-v3 Certificate Store Type To use the AWS Certificate Manager (ACM) Universal Orchestrator extension, you **must** create the AWS-ACM-v3 Certificate Store Type. This only needs to happen _once_ per Keyfactor Command instance. -The AWS Certificate Manager v3 Store Type provides enhanced authentication options for managing certificates in ACM. -Each defined Certificate Store of this type targets a specific AWS Region with a specific Destination account in mind. -Therefore, each Certificate Store instance is intended to represent a single Role's certificates in a single AWS Region. +The `AWS-ACM-v3` store type represents **one AWS region inside one AWS account**. Certificate +aliases within the store are full ACM certificate ARNs, and the region plus the identity to operate +as are both carried by the store's `Client Machine` and `Store Path` fields. -Some authentication configurations do not adhere strictly to this, so when using the various methods offered in the Default SDK auth option, -a full understanding of how permissions work in AWS is recommended. -In most scenarios using the Default SDK option, the Assume Role flag should also be set to avoid confusion, and use the Role ARN in the `Client Machine` field as the Destination account. +Define a separate certificate store for every region you hold certificates in; a single store cannot +span regions. -The latest version of the Store Type supporting ACM (AWS Certificate Manager) is `AWS-ACM-v3`. -Previous store types are no longer supported and should be migrated to the new Store Type definition. -When migrating to the `AWS-ACM-v3` type please note that field usage has changed and does not map over directly. +##### Store Path and Client Machine -> [!WARNING] -> When creating Certificate Stores, all available Secret type fields need to have a value set for them, even if that is "No Value". -> Failing to set these Secret fields, even when not in use, causes errors that may require database access to fix. +`Store Path` accepts two formats, and which one you use decides where the Role ARN comes from: -#### AWS Certificate Manager v3 Requirements +| Format | Example `Store Path` | Role ARN comes from | `Client Machine` | +|---|---|---|---| +| Region only (legacy) | `us-east-1` | the `Client Machine` field | **Required** — full Role ARN | +| `\|` | `arn:aws:iam::123456789012:role/Keyfactor-ACM-Role\|us-east-1` | the `Store Path` itself | Informational only | + +Stores created by cross-account Discovery always use the second form. Both are supported +indefinitely — existing region-only stores do not need to be rewritten. + +`Client Machine` may also carry a **credential profile prefix** when using Default SDK auth, e.g. +`[myprofile]arn:aws:iam::123456789012:role/Keyfactor-ACM-Role`. The named profile must exist in the +shared AWS credentials file on the orchestrator host. + +##### Which identity actually reaches ACM + +Every authentication method resolves to a single **destination identity** — the assumed Role ARN +when the store performs an Assume Role, or the originating identity (ambient SDK credentials or the +named profile) when it does not. **The destination identity is the one that needs the ACM +permissions.** + +For every Assume-Role method the **originating** identity additionally needs `sts:AssumeRole` on the +target role, and that role's trust policy must allow the originating principal. -Configuring authentication with AWS requires understanding how the authentication flow works. -Depending on the intended authentication method, the required configuration in AWS may require one or more Roles with the correct permissions. +> [!TIP] +> When using Default SDK auth, also set `DefaultSdkAssumeRole` and put the destination Role ARN in +> `Client Machine`. Without it, the identity reaching ACM is whatever the SDK happened to resolve on +> the orchestrator host, which is easy to misconfigure and hard to diagnose. -The intended Destination account, usually the Role ARN specified in the `Client Machine` field, which is the final identity used to perform the actual work in AWS ACM, needs the following permissions: +#### Store type version and required field values -~~~ -Inventory required actions: +The latest Store Type supporting ACM (AWS Certificate Manager) is `AWS-ACM-v3`. The earlier +`AWS-ACM`, `AwsCerManO` and `AwsCerManA` types are no longer supported and must be migrated — +field usage changed and does **not** map across directly, so stores have to be recreated rather than +converted. See +[Migrating from an older ACM store type](#migrating-from-an-older-acm-store-type). - "acm:ListCertificates", - "acm:GetCertificate", - "acm:ListTagsForCertificate" +> [!WARNING] +> When creating Certificate Stores, **all available Secret type fields need to have a value set for +> them, even if that is "No Value"**. The secret fields on this store type are `OAuthClientId`, +> `OAuthClientSecret`, `IAMUserAccessKey` and `IAMUserAccessSecret`. Failing to set these Secret +> fields, even when not in use, causes errors that may require database access to fix. + +> [!NOTE] +> `Supports Reenrollment` is unchecked and cannot be enabled: ACM provides no CSR-generation API for +> imported certificates, so there is no on-device key generation path to implement. + +#### AWS Certificate Manager v3 Requirements - Management required actions: +Grant the following ACM actions to the **destination identity**. Copy/adapt JSON policy documents — +including the separate policies needed for cross-account Discovery — are in +[Example IAM policies for the destination identity](#example-iam-policies-for-the-destination-identity), +and matching trust policies are in [Example trust policies](#example-trust-policies). - "acm:DeleteCertificate", - "acm:DescribeCertificate", - "acm:ImportCertificate" -~~~ +| Job type | Required actions | Notes | +|---|---|---| +| **Inventory** | `acm:ListCertificates`
`acm:GetCertificate`
`acm:ListTagsForCertificate` | `ListCertificates` cannot be scoped to a resource and must be granted on `*` | +| **Management** (add / renew / remove) | `acm:DescribeCertificate`
`acm:ImportCertificate`
`acm:DeleteCertificate` | Renewal is an `ImportCertificate` call supplying the existing ARN, so no extra action is needed | +| **Management** with the `ACM Tags` entry parameter | *(none beyond `acm:ImportCertificate`)* | Tags on import are governed by `acm:ImportCertificate`, which carries the `aws:RequestTag`/`aws:TagKeys` condition keys. Add `acm:AddTagsToCertificate` only if your policy standards require it to be explicit | +| **Discovery** (management identity) | `organizations:ListAccounts`
`sts:AssumeRole` | If `ListAccounts` is denied, Discovery logs a warning and falls back to single-account discovery | +| **Discovery** (per-account role) | `acm:ListCertificates`
`ec2:DescribeRegions` | `DescribeRegions` is only used when the region list is blank or `*` | + +A store that only needs Inventory does not need the Management actions; grant the narrowest set the +store's jobs actually use. #### Supported Operations @@ -273,7 +814,7 @@ the Keyfactor Command Portal | UseOAuth | Use OAuth 2.0 Provider | A switch to enable the store to use an OAuth provider workflow to authenticate with AWS | Bool | false | ✅ Checked | | OAuthScope | OAuth Scope | This is the OAuth Scope needed for Okta OAuth, defined in Okta | String | | 🔲 Unchecked | | OAuthGrantType | OAuth Grant Type | In OAuth 2.0, the term 'grant type' refers to the way an application gets an access token. In Okta this is `client_credentials` | String | client_credentials | 🔲 Unchecked | - | OAuthUrl | OAuth Url | An optional parameter sts:ExternalId to pass with Assume Role calls | String | https://***/oauth2/default/v1/token | 🔲 Unchecked | + | OAuthUrl | OAuth Url | The token endpoint URL of the OAuth 2.0 / OIDC identity provider, for example https://your-tenant.okta.com/oauth2/default/v1/token | String | https://***/oauth2/default/v1/token | 🔲 Unchecked | | OAuthClientId | OAuth Client ID | The Client ID for OAuth. | Secret | | 🔲 Unchecked | | OAuthClientSecret | OAuth Client Secret | The Client Secret for OAuth. | Secret | | 🔲 Unchecked | | UseIAM | Use IAM User Auth | A switch to enable the store to use IAM User auth to assume a role when authenticating with AWS | Bool | false | ✅ Checked | @@ -290,91 +831,78 @@ the Keyfactor Command Portal A switch to enable the store to use Default SDK credentials ![AWS-ACM-v3 Custom Field - UseDefaultSdkAuth](docsource/images/AWS-ACM-v3-custom-field-UseDefaultSdkAuth-dialog.svg) - ![AWS-ACM-v3 Custom Field - UseDefaultSdkAuth](docsource/images/AWS-ACM-v3-custom-field-UseDefaultSdkAuth-validation-options-dialog.svg) ###### Assume new Role using Default SDK Auth A switch to enable the store to assume a new Role when using Default SDK credentials ![AWS-ACM-v3 Custom Field - DefaultSdkAssumeRole](docsource/images/AWS-ACM-v3-custom-field-DefaultSdkAssumeRole-dialog.svg) - ![AWS-ACM-v3 Custom Field - DefaultSdkAssumeRole](docsource/images/AWS-ACM-v3-custom-field-DefaultSdkAssumeRole-validation-options-dialog.svg) ###### Use OAuth 2.0 Provider A switch to enable the store to use an OAuth provider workflow to authenticate with AWS ![AWS-ACM-v3 Custom Field - UseOAuth](docsource/images/AWS-ACM-v3-custom-field-UseOAuth-dialog.svg) - ![AWS-ACM-v3 Custom Field - UseOAuth](docsource/images/AWS-ACM-v3-custom-field-UseOAuth-validation-options-dialog.svg) ###### OAuth Scope This is the OAuth Scope needed for Okta OAuth, defined in Okta ![AWS-ACM-v3 Custom Field - OAuthScope](docsource/images/AWS-ACM-v3-custom-field-OAuthScope-dialog.svg) - ![AWS-ACM-v3 Custom Field - OAuthScope](docsource/images/AWS-ACM-v3-custom-field-OAuthScope-validation-options-dialog.svg) ###### OAuth Grant Type In OAuth 2.0, the term 'grant type' refers to the way an application gets an access token. In Okta this is `client_credentials` ![AWS-ACM-v3 Custom Field - OAuthGrantType](docsource/images/AWS-ACM-v3-custom-field-OAuthGrantType-dialog.svg) - ![AWS-ACM-v3 Custom Field - OAuthGrantType](docsource/images/AWS-ACM-v3-custom-field-OAuthGrantType-validation-options-dialog.svg) ###### OAuth Url - An optional parameter sts:ExternalId to pass with Assume Role calls + The token endpoint URL of the OAuth 2.0 / OIDC identity provider, for example https://your-tenant.okta.com/oauth2/default/v1/token ![AWS-ACM-v3 Custom Field - OAuthUrl](docsource/images/AWS-ACM-v3-custom-field-OAuthUrl-dialog.svg) - ![AWS-ACM-v3 Custom Field - OAuthUrl](docsource/images/AWS-ACM-v3-custom-field-OAuthUrl-validation-options-dialog.svg) ###### OAuth Client ID The Client ID for OAuth. ![AWS-ACM-v3 Custom Field - OAuthClientId](docsource/images/AWS-ACM-v3-custom-field-OAuthClientId-dialog.svg) - ![AWS-ACM-v3 Custom Field - OAuthClientId](docsource/images/AWS-ACM-v3-custom-field-OAuthClientId-validation-options-dialog.svg) ###### OAuth Client Secret The Client Secret for OAuth. ![AWS-ACM-v3 Custom Field - OAuthClientSecret](docsource/images/AWS-ACM-v3-custom-field-OAuthClientSecret-dialog.svg) - ![AWS-ACM-v3 Custom Field - OAuthClientSecret](docsource/images/AWS-ACM-v3-custom-field-OAuthClientSecret-validation-options-dialog.svg) ###### Use IAM User Auth A switch to enable the store to use IAM User auth to assume a role when authenticating with AWS ![AWS-ACM-v3 Custom Field - UseIAM](docsource/images/AWS-ACM-v3-custom-field-UseIAM-dialog.svg) - ![AWS-ACM-v3 Custom Field - UseIAM](docsource/images/AWS-ACM-v3-custom-field-UseIAM-validation-options-dialog.svg) ###### IAM User Access Key The AWS Access Key for an IAM User ![AWS-ACM-v3 Custom Field - IAMUserAccessKey](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessKey-dialog.svg) - ![AWS-ACM-v3 Custom Field - IAMUserAccessKey](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessKey-validation-options-dialog.svg) ###### IAM User Access Secret The AWS Access Secret for an IAM User. ![AWS-ACM-v3 Custom Field - IAMUserAccessSecret](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessSecret-dialog.svg) - ![AWS-ACM-v3 Custom Field - IAMUserAccessSecret](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessSecret-validation-options-dialog.svg) ###### sts:ExternalId An optional parameter sts:ExternalId to pass with Assume Role calls ![AWS-ACM-v3 Custom Field - ExternalId](docsource/images/AWS-ACM-v3-custom-field-ExternalId-dialog.svg) - ![AWS-ACM-v3 Custom Field - ExternalId](docsource/images/AWS-ACM-v3-custom-field-ExternalId-validation-options-dialog.svg) ###### Discovery Role Name The IAM role name that exists in each target account. Used during cross-account Discovery to construct the Role ARN for cross-account access (arn:aws:iam:::role/). During a Discovery job this is supplied via the 'File name patterns to match' dialog field; this store property documents and defaults the value. ![AWS-ACM-v3 Custom Field - DiscoveryRoleName](docsource/images/AWS-ACM-v3-custom-field-DiscoveryRoleName-dialog.svg) - ![AWS-ACM-v3 Custom Field - DiscoveryRoleName](docsource/images/AWS-ACM-v3-custom-field-DiscoveryRoleName-validation-options-dialog.svg) ##### Entry Parameters Tab @@ -390,7 +918,6 @@ the Keyfactor Command Portal The optional ACM tags that should be assigned to the certificate. Multiple name/value pairs may be entered in the format of `Name1=Value1,Name2=Value2,...,NameN=ValueN` ![AWS-ACM-v3 Entry Parameter - ACM Tags](docsource/images/AWS-ACM-v3-entry-parameters-store-type-dialog-ACM Tags.svg) - ![AWS-ACM-v3 Entry Parameter - ACM Tags](docsource/images/AWS-ACM-v3-entry-parameters-store-type-dialog-ACM Tags-validation-options.svg) @@ -399,9 +926,9 @@ the Keyfactor Command Portal 1. **Download the latest AWS Certificate Manager (ACM) Universal Orchestrator extension from GitHub.** - Navigate to the [AWS Certificate Manager (ACM) Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/aws-orchestrator/releases/latest). Refer to the compatibility matrix below to determine which asset should be downloaded. Then, click the corresponding asset to download the zip archive. + Navigate to the [AWS Certificate Manager (ACM) Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/aws-acm-orchestrator-dev/releases/latest). Refer to the compatibility matrix below to determine which asset should be downloaded. Then, click the corresponding asset to download the zip archive. - | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `aws-orchestrator` .NET version to download | + | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `aws-acm-orchestrator-dev` .NET version to download | | --------- | ----------- | ----------- | ----------- | | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | | `11.6` _and_ newer | `net8.0` | | `net8.0` | @@ -418,10 +945,10 @@ the Keyfactor Command Portal 3. **Create a new directory for the AWS Certificate Manager (ACM) Universal Orchestrator extension inside the extensions directory.** - Create a new directory called `aws-orchestrator`. + Create a new directory called `aws-acm-orchestrator-dev`. > The directory name does not need to match any names used elsewhere; it just has to be unique within the extensions directory. -4. **Copy the contents of the downloaded and unzipped assemblies from __step 2__ to the `aws-orchestrator` directory.** +4. **Copy the contents of the downloaded and unzipped assemblies from __step 2__ to the `aws-acm-orchestrator-dev` directory.** 5. **Restart the Universal Orchestrator service.** @@ -437,13 +964,23 @@ the Keyfactor Command Portal ## Defining Certificate Stores -The latest version of the Store Type supporting ACM (AWS Certificate Manager) is `AWS-ACM-v3`. -Previous store types are no longer supported and should be migrated to the new Store Type definition. -When migrating to the `AWS-ACM-v3` type please note that field usage has changed and does not map over directly. +#### Store type version and required field values + +The latest Store Type supporting ACM (AWS Certificate Manager) is `AWS-ACM-v3`. The earlier +`AWS-ACM`, `AwsCerManO` and `AwsCerManA` types are no longer supported and must be migrated — +field usage changed and does **not** map across directly, so stores have to be recreated rather than +converted. See +[Migrating from an older ACM store type](#migrating-from-an-older-acm-store-type). > [!WARNING] -> When creating Certificate Stores, all available Secret type fields need to have a value set for them, even if that is "No Value". -> Failing to set these Secret fields, even when not in use, causes errors that may require database access to fix. +> When creating Certificate Stores, **all available Secret type fields need to have a value set for +> them, even if that is "No Value"**. The secret fields on this store type are `OAuthClientId`, +> `OAuthClientSecret`, `IAMUserAccessKey` and `IAMUserAccessSecret`. Failing to set these Secret +> fields, even when not in use, causes errors that may require database access to fix. + +> [!NOTE] +> `Supports Reenrollment` is unchecked and cannot be enabled: ACM provides no CSR-generation API for +> imported certificates, so there is no on-device key generation path to implement. ### Store Creation @@ -471,7 +1008,7 @@ When migrating to the `AWS-ACM-v3` type please note that field usage has changed | UseOAuth | A switch to enable the store to use an OAuth provider workflow to authenticate with AWS | | OAuthScope | This is the OAuth Scope needed for Okta OAuth, defined in Okta | | OAuthGrantType | In OAuth 2.0, the term 'grant type' refers to the way an application gets an access token. In Okta this is `client_credentials` | - | OAuthUrl | An optional parameter sts:ExternalId to pass with Assume Role calls | + | OAuthUrl | The token endpoint URL of the OAuth 2.0 / OIDC identity provider, for example https://your-tenant.okta.com/oauth2/default/v1/token | | OAuthClientId | The Client ID for OAuth. | | OAuthClientSecret | The Client Secret for OAuth. | | UseIAM | A switch to enable the store to use IAM User auth to assume a role when authenticating with AWS | @@ -507,7 +1044,7 @@ When migrating to the `AWS-ACM-v3` type please note that field usage has changed | Properties.UseOAuth | A switch to enable the store to use an OAuth provider workflow to authenticate with AWS | | Properties.OAuthScope | This is the OAuth Scope needed for Okta OAuth, defined in Okta | | Properties.OAuthGrantType | In OAuth 2.0, the term 'grant type' refers to the way an application gets an access token. In Okta this is `client_credentials` | - | Properties.OAuthUrl | An optional parameter sts:ExternalId to pass with Assume Role calls | + | Properties.OAuthUrl | The token endpoint URL of the OAuth 2.0 / OIDC identity provider, for example https://your-tenant.okta.com/oauth2/default/v1/token | | Properties.OAuthClientId | The Client ID for OAuth. | | Properties.OAuthClientSecret | The Client Secret for OAuth. | | Properties.UseIAM | A switch to enable the store to use IAM User auth to assume a role when authenticating with AWS | diff --git a/aws-acm-orchestrator.Tests/Jobs/AuthDescriptionTests.cs b/aws-acm-orchestrator.Tests/Jobs/AuthDescriptionTests.cs new file mode 100644 index 0000000..2ed8a8b --- /dev/null +++ b/aws-acm-orchestrator.Tests/Jobs/AuthDescriptionTests.cs @@ -0,0 +1,103 @@ +// 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.Aws.Models; +using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.Jobs +{ + /// + /// Every job logs which of the four supported credential methods it took, in the auth library's + /// vocabulary. The strings are a deliberate, greppable contract (operators and the test harnesses + /// match on them), so they are asserted literally. + /// + public class AuthDescriptionTests + { + private const string RoleArn = "arn:aws:iam::905418207372:role/iam-ACM-access"; + private const string ProfileRef = "[kfprofile]arn:aws:iam::905418207372:role/iam-ACM-access"; + + [Fact] + public void NullCustomFields_IsUnknown() + => Assert.Equal("Unknown", AuthDescription.DescribeMethod(null, RoleArn)); + + [Fact] + public void NoMethodSelected_IsUnknown() + => Assert.Equal("Unknown", AuthDescription.DescribeMethod(new AuthCustomFieldParameters(), RoleArn)); + + [Fact] + public void OAuth_WinsOverEverythingElse() + { + // Precedence is OAuth > IAM > DefaultSdk. Asserted with all three set so a reordering of the + // branches is caught rather than passing by luck. + var cf = new AuthCustomFieldParameters + { + UseOAuth = true, + UseIAM = true, + UseDefaultSdkAuth = true, + DefaultSdkAssumeRole = true + }; + + Assert.Equal("OAuthProvider", AuthDescription.DescribeMethod(cf, RoleArn)); + } + + [Fact] + public void Iam_WinsOverDefaultSdk() + { + var cf = new AuthCustomFieldParameters + { + UseIAM = true, + UseDefaultSdkAuth = true, + DefaultSdkAssumeRole = true + }; + + Assert.Equal("IamUser", AuthDescription.DescribeMethod(cf, RoleArn)); + } + + [Theory] + [InlineData(RoleArn, false, "DefaultSdk")] + [InlineData(RoleArn, true, "DefaultSdk_AssumeRole")] + [InlineData(ProfileRef, false, "DefaultSdk_CredentialProfile")] + [InlineData(ProfileRef, true, "DefaultSdk_CredentialProfile_AssumeRole")] + [InlineData(null, false, "DefaultSdk")] + [InlineData("", true, "DefaultSdk_AssumeRole")] + public void DefaultSdk_DistinguishesProfileAndAssumeRole(string roleArn, bool assumeRole, string expected) + { + var cf = new AuthCustomFieldParameters + { + UseDefaultSdkAuth = true, + DefaultSdkAssumeRole = assumeRole + }; + + Assert.Equal(expected, AuthDescription.DescribeMethod(cf, roleArn)); + } + + [Fact] + public void CredentialProfile_IsDetectedFromLeadingBracketAfterTrimming() + { + // The auth library's marker is a "[profile]" prefix; leading whitespace must not hide it. + var cf = new AuthCustomFieldParameters { UseDefaultSdkAuth = true }; + + Assert.Equal("DefaultSdk_CredentialProfile", AuthDescription.DescribeMethod(cf, " [kfprofile]whatever")); + } + + [Fact] + public void BracketLaterInTheString_IsNotACredentialProfile() + { + var cf = new AuthCustomFieldParameters { UseDefaultSdkAuth = true }; + + Assert.Equal("DefaultSdk", AuthDescription.DescribeMethod(cf, "arn:aws:iam::1:role/weird[name]")); + } + } +} diff --git a/aws-acm-orchestrator.Tests/Jobs/DiscoveryTests.cs b/aws-acm-orchestrator.Tests/Jobs/DiscoveryTests.cs new file mode 100644 index 0000000..6ab81db --- /dev/null +++ b/aws-acm-orchestrator.Tests/Jobs/DiscoveryTests.cs @@ -0,0 +1,286 @@ +// 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 Amazon; +using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs; +using Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.TestUtilities; +using System.Collections.Generic; +using System.Linq; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.Jobs +{ + public class DiscoveryTests + { + private readonly ITestOutputHelper _output; + + public DiscoveryTests(ITestOutputHelper output) => _output = output; + + // --------------------------------------------------------------------------------- + // Per-account role ARN construction for cross-account discovery + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData("aws", "arn:aws:iam::905418207372:role/KeyfactorACMDiscoveryRole")] + [InlineData("aws-us-gov", "arn:aws-us-gov:iam::905418207372:role/KeyfactorACMDiscoveryRole")] + [InlineData("aws-cn", "arn:aws-cn:iam::905418207372:role/KeyfactorACMDiscoveryRole")] + public void MemberRoleArn_UsesThePartitionOfTheManagementAccount(string partition, string expected) + => Assert.Equal(expected, Discovery.BuildMemberRoleArn(partition, "905418207372", "KeyfactorACMDiscoveryRole")); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("aws-iso")] + public void UnknownPartition_FallsBackToTheCommercialPartition(string partition) + { + // IAM ARNs are global per partition, so an unrecognised partition name degrades to "aws" + // rather than producing an unusable ARN. + Assert.Equal("arn:aws:iam::1:role/R", Discovery.BuildMemberRoleArn(partition, "1", "R")); + } + + [Fact] + public void MemberRoleArn_HasNoRegionSegment() + { + // IAM is global; a region in this ARN would make every AssumeRole call fail. + string arn = Discovery.BuildMemberRoleArn("aws", "905418207372", "R"); + + Assert.Equal("arn:aws:iam::905418207372:role/R", arn); + Assert.Contains("iam::", arn); + } + + // --------------------------------------------------------------------------------- + // Organizations is a single global endpoint per partition + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData("aws", "us-east-1")] + [InlineData("aws-us-gov", "us-gov-west-1")] + [InlineData("aws-cn", "cn-northwest-1")] + [InlineData("something-else", "us-east-1")] + public void OrganizationsRegion_IsThePartitionGlobalEndpoint(string partition, string expectedRegion) + => Assert.Equal(expectedRegion, Discovery.GetOrganizationsRegion(partition).SystemName); + + // --------------------------------------------------------------------------------- + // Region list resolution + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("*")] + [InlineData(" * ")] + public void BlankOrWildcardRegions_MeanEveryRegionInThePartition(string config) + { + // NOTE: CLAUDE.md described this as defaulting to "16 common ACM regions". It does not - + // it returns the SDK's full region list for the partition. These assertions pin the real + // behaviour. + var regions = TestableDiscovery.Create(_output).ParseRegions(config, "aws"); + + Assert.Contains(regions, r => r.SystemName == "us-east-1"); + Assert.Contains(regions, r => r.SystemName == "eu-west-1"); + Assert.True(regions.Count > 16, $"expected the full partition list, got {regions.Count}"); + Assert.All(regions, r => Assert.Equal("aws", r.PartitionName)); + } + + [Fact] + public void WildcardIsScopedToTheRequestedPartition() + { + var commercial = TestableDiscovery.Create(_output).ParseRegions("*", "aws"); + + // A commercial-partition scan must never reach into GovCloud or China. + Assert.DoesNotContain(commercial, r => r.SystemName.StartsWith("us-gov-")); + Assert.DoesNotContain(commercial, r => r.SystemName.StartsWith("cn-")); + } + + [Fact] + public void GovCloudPartition_ReturnsOnlyGovCloudRegions() + { + var gov = TestableDiscovery.Create(_output).ParseRegions("*", "aws-us-gov"); + + Assert.NotEmpty(gov); + Assert.All(gov, r => Assert.StartsWith("us-gov-", r.SystemName)); + } + + [Fact] + public void ExplicitRegionList_IsUsedVerbatimAndInOrder() + { + var regions = TestableDiscovery.Create(_output).ParseRegions("us-east-2,eu-west-1,ap-south-1", "aws"); + + Assert.Equal(new[] { "us-east-2", "eu-west-1", "ap-south-1" }, regions.Select(r => r.SystemName)); + } + + [Fact] + public void ExplicitRegionList_TolerantOfSpacingAndTrailingSeparators() + { + var regions = TestableDiscovery.Create(_output).ParseRegions(" us-east-2 , , eu-west-1 ,", "aws"); + + Assert.Equal(new[] { "us-east-2", "eu-west-1" }, regions.Select(r => r.SystemName)); + } + + [Fact] + public void ExplicitRegionList_IgnoresThePartitionArgument() + { + // The partition only gates the wildcard path. An explicitly named region is taken at face + // value, so an operator can pin a region the partition filter would have excluded. + var regions = TestableDiscovery.Create(_output).ParseRegions("us-east-1", "aws-cn"); + + Assert.Equal("us-east-1", Assert.Single(regions).SystemName); + } + + // --------------------------------------------------------------------------------- + // CSV splitting + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SplitCsv_OfBlank_IsEmpty(string value) + => Assert.Empty(Discovery.SplitCsv(value)); + + [Fact] + public void SplitCsv_TrimsAndDropsEmptyEntries() + => Assert.Equal(new[] { "a", "b", "c" }, Discovery.SplitCsv(" a , b ,, c , ")); + + [Fact] + public void SplitCsv_OfASingleValue_YieldsOneEntry() + => Assert.Equal(new[] { "solo" }, Discovery.SplitCsv("solo")); + + [Fact] + public void SplitCsv_PreservesInternalSpacesWithinAnEntry() + => Assert.Equal(new[] { "two words" }, Discovery.SplitCsv(" two words ")); + + // --------------------------------------------------------------------------------- + // FirstNonEmpty + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData("a", "b", "a")] + [InlineData(null, "b", "b")] + [InlineData("", "b", "b")] + [InlineData(" ", "b", "b")] + [InlineData(null, null, null)] + public void FirstNonEmpty_PrefersTheFirstMeaningfulValue(string a, string b, string expected) + => Assert.Equal(expected, Discovery.FirstNonEmpty(a, b)); + + // --------------------------------------------------------------------------------- + // JobProperties lookup. DiscoveryJobConfiguration has no StorePath, so every setting + // arrives through this loosely typed dictionary. + // --------------------------------------------------------------------------------- + + [Fact] + public void JobProperty_OfNullDictionary_IsNull() + => Assert.Null(TestableDiscovery.Create(_output).GetJobProperty(null, "Region")); + + [Fact] + public void JobProperty_OfMissingKey_IsNull() + => Assert.Null(TestableDiscovery.Create(_output) + .GetJobProperty(new Dictionary(), "Region")); + + [Fact] + public void JobProperty_OfNullValue_IsNull() + => Assert.Null(TestableDiscovery.Create(_output) + .GetJobProperty(new Dictionary { { "Region", null } }, "Region")); + + [Fact] + public void JobProperty_StringifiesNonStringValues() + { + // Values arrive as object from Command and are not guaranteed to be strings. + var props = new Dictionary { { "Count", 42 }, { "Flag", true } }; + var disc = TestableDiscovery.Create(_output); + + Assert.Equal("42", disc.GetJobProperty(props, "Count")); + Assert.Equal("True", disc.GetJobProperty(props, "Flag")); + } + + [Fact] + public void JobProperty_IsCaseSensitive_UnlikeJobPropertyMulti() + { + // Documents the difference between the two lookups: the single-key form is an ordinary + // dictionary hit, which is why the dialog-driven fields use the Multi form instead. + var props = new Dictionary { { "Region", "us-east-2" } }; + + Assert.Null(TestableDiscovery.Create(_output).GetJobProperty(props, "region")); + } + + [Fact] + public void JobPropertyMulti_MatchesKeysCaseInsensitively() + { + // The Discovery dialog packs its fields into JobProperties under a key whose casing varies + // by Command version, which is the whole reason this lookup exists. + var props = new Dictionary { { "DIRS", "arn:aws:iam::1:role/R" } }; + + Assert.Equal("arn:aws:iam::1:role/R", + TestableDiscovery.Create(_output).GetJobPropertyMulti(props, "dirs", "DirectoriesToSearch")); + } + + [Fact] + public void JobPropertyMulti_TriesEachCandidateKeyInOrder() + { + var props = new Dictionary { { "DirectoriesToSearch", "second-choice" } }; + + Assert.Equal("second-choice", + TestableDiscovery.Create(_output).GetJobPropertyMulti(props, "dirs", "DirectoriesToSearch")); + } + + [Fact] + public void JobPropertyMulti_SkipsAKeyPresentButBlank() + { + var props = new Dictionary + { + { "dirs", " " }, + { "DirectoriesToSearch", "real-value" } + }; + + Assert.Equal("real-value", + TestableDiscovery.Create(_output).GetJobPropertyMulti(props, "dirs", "DirectoriesToSearch")); + } + + [Fact] + public void JobPropertyMulti_OfNullDictionaryOrNoMatch_IsNull() + { + var disc = TestableDiscovery.Create(_output); + + Assert.Null(disc.GetJobPropertyMulti(null, "dirs")); + Assert.Null(disc.GetJobPropertyMulti(new Dictionary(), "dirs")); + } + + // --------------------------------------------------------------------------------- + // Discovered store paths + // --------------------------------------------------------------------------------- + + [Fact] + public void DiscoveredStorePath_IsSelfContainedAndReParsable() + { + // Discovery has no channel to set ClientMachine per discovered store, so the path has to + // carry both halves of the store identity. Inventory and Management must be able to read + // back exactly what Discovery emitted. + string roleArn = "arn:aws:iam::905418207372:role/KeyfactorACMDiscoveryRole"; + string path = StorePathParser.Build(roleArn, "us-east-2"); + + var (parsedRole, parsedRegion) = StorePathParser.Parse(path, clientMachine: "UOLinux"); + + Assert.Equal(roleArn, parsedRole); + Assert.Equal("us-east-2", parsedRegion); + } + + [Fact] + public void ExtensionName_MatchesTheRegisteredStoreType() + { + // Must equal the store type registered in Command and the class names in manifest.json. + Assert.Equal("AWS-ACM-v3", TestableDiscovery.Create(_output).ExtensionName); + } + } +} diff --git a/aws-acm-orchestrator.Tests/Jobs/FilterParityTests.cs b/aws-acm-orchestrator.Tests/Jobs/FilterParityTests.cs new file mode 100644 index 0000000..3a61a05 --- /dev/null +++ b/aws-acm-orchestrator.Tests/Jobs/FilterParityTests.cs @@ -0,0 +1,151 @@ +// 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 Amazon.CertificateManager; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.Jobs +{ + /// + /// Guards the ListCertificates filter lists. + /// + /// Two facts make this necessary. First, ACM's filters are *inclusion* filters: anything not + /// named is silently absent from the result with no error, which is how ACME-origin certificates + /// and RSA-3072 certificates went missing in earlier releases. Second, + /// and are AWS ConstantClass + /// types rather than C# enums, so production code cannot enumerate them at run time - the lists + /// are maintained by hand and a new AWS value is invisible until someone notices missing + /// certificates in Command. + /// + /// Tests cannot call PerformInventory directly (it reassigns AcmClient to a real + /// AmazonCertificateManagerClient before touching the filters), so these assertions read the + /// source files instead. That is unusual, and deliberate: it is the only non-invasive way to pin + /// the invariant. If a CreateAcmClient seam is ever added to Inventory, replace these with real + /// behavioural tests against a mocked client. + /// + public class FilterParityTests + { + private static string RepoRoot([CallerFilePath] string thisFile = null) + => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile), "..", "..")); + + private static string ReadJobSource(string fileName) + { + string path = Path.Combine(RepoRoot(), "aws-acm-orchestrator", "Jobs", fileName); + Assert.True(File.Exists(path), $"expected to find job source at {path}"); + return File.ReadAllText(path); + } + + /// Every Prefix.VALUE reference in the source, as a set of VALUE names. + private static HashSet ReferencedConstants(string source, string typeName) + => new HashSet( + Regex.Matches(source, $@"(?[A-Za-z0-9_]+)") + .Cast() + .Select(m => m.Groups["name"].Value), + StringComparer.Ordinal); + + /// Every constant the AWS SDK declares on a ConstantClass type. + private static HashSet SdkConstants() + => new HashSet( + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(T)) + .Select(f => f.Name), + StringComparer.Ordinal); + + [Fact] + public void InventoryRequestsEveryKeyPairOriginTheSdkDefines() + { + var expected = SdkConstants(); + var referenced = ReferencedConstants(ReadJobSource("Inventory.cs"), "CertificateKeyPairOrigin"); + + Assert.NotEmpty(expected); + AssertCovers(expected, referenced, "Inventory.cs", "CertificateKeyPairOrigin"); + } + + [Fact] + public void DiscoveryRequestsEveryKeyPairOriginTheSdkDefines() + { + var expected = SdkConstants(); + var referenced = ReferencedConstants(ReadJobSource("Discovery.cs"), "CertificateKeyPairOrigin"); + + Assert.NotEmpty(expected); + AssertCovers(expected, referenced, "Discovery.cs", "CertificateKeyPairOrigin"); + } + + [Fact] + public void InventoryAndDiscoveryRequestTheSameKeyPairOrigins() + { + // The Discovery probe decides whether a region gets a certificate store at all. If it + // filters more narrowly than Inventory, a region full of certificates looks empty and no + // store is ever created for it - the 3.1.1 ACME bug. + var inventory = ReferencedConstants(ReadJobSource("Inventory.cs"), "CertificateKeyPairOrigin"); + var discovery = ReferencedConstants(ReadJobSource("Discovery.cs"), "CertificateKeyPairOrigin"); + + Assert.Equal(inventory.OrderBy(s => s), discovery.OrderBy(s => s)); + } + + [Theory] + [InlineData("Inventory.cs")] + [InlineData("Discovery.cs")] + public void BothJobsRequestEveryKeyAlgorithmTheSdkDefines(string fileName) + { + // Includes.KeyTypes works around ACM omitting certificates that have no SAN. Leaving an + // algorithm out hides every SAN-less certificate using it - this is how RSA_3072 + // certificates were missing before 3.1.1. + var expected = SdkConstants(); + var referenced = ReferencedConstants(ReadJobSource(fileName), "KeyAlgorithm"); + + Assert.NotEmpty(expected); + AssertCovers(expected, referenced, fileName, "KeyAlgorithm"); + } + + [Fact] + public void InventoryAndDiscoveryRequestTheSameKeyAlgorithms() + { + // Same hazard as the origin lists, and the one this guard originally missed: the Discovery + // probe applied no KeyTypes filter at all, so a region whose certificates all lacked a SAN + // looked empty and no store was created for it. + var inventory = ReferencedConstants(ReadJobSource("Inventory.cs"), "KeyAlgorithm"); + var discovery = ReferencedConstants(ReadJobSource("Discovery.cs"), "KeyAlgorithm"); + + Assert.Equal(inventory.OrderBy(s => s), discovery.OrderBy(s => s)); + } + + private static void AssertCovers(HashSet expected, HashSet referenced, + string fileName, string typeName) + { + var missing = expected.Except(referenced).OrderBy(s => s).ToList(); + + Assert.True(missing.Count == 0, + $"{fileName} does not request {typeName}.{{{string.Join(", ", missing)}}}. " + + $"ACM filters are inclusion filters, so certificates matching the missing " + + $"value(s) will be silently absent from the result. Add them to the filter list " + + $"(and to the matching list in the other job) rather than relaxing this test."); + } + + [Fact] + public void TheSdkConstantsAreNotEnumerableAtRunTime_WhichIsWhyTheseTestsExist() + { + // Documents the constraint these tests work around: ConstantClass is not an enum, so + // Enum.GetValues cannot be used in production code to build the filter list. + Assert.False(typeof(CertificateKeyPairOrigin).IsEnum); + Assert.False(typeof(KeyAlgorithm).IsEnum); + } + } +} diff --git a/aws-acm-orchestrator.Tests/Jobs/InventoryTests.cs b/aws-acm-orchestrator.Tests/Jobs/InventoryTests.cs new file mode 100644 index 0000000..90aeeaf --- /dev/null +++ b/aws-acm-orchestrator.Tests/Jobs/InventoryTests.cs @@ -0,0 +1,326 @@ +// 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 Amazon.CertificateManager; +using Amazon.CertificateManager.Model; +using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs; +using Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.TestUtilities; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.Jobs +{ + public class InventoryTests + { + private readonly ITestOutputHelper _output; + + public InventoryTests(ITestOutputHelper output) => _output = output; + + private const string Arn = "arn:aws:acm:us-east-2:905418207372:certificate/fac411f1-a478-4a8b-9680-eee1df74ca2d"; + + private static KeyValuePair[] Tags(params (string K, string V)[] pairs) + => pairs.Select(p => new KeyValuePair(p.K, p.V)).ToArray(); + + // --------------------------------------------------------------------------------- + // PrivateKeyEntry is derived from the certificate Type (v3.1.1). + // + // ACM holds a private key only for user-imported certificates. Reporting the ones it + // issues itself as private key entries made Command offer a renewal that Management is + // guaranteed to reject, so the flag must follow Type and nothing else. + // --------------------------------------------------------------------------------- + + [Fact] + public void ImportedCertificate_IsAPrivateKeyEntry() + { + var inv = new TestableInventory(AcmMocks.Client().Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + Assert.True(item.PrivateKeyEntry); + } + + [Theory] + [InlineData("AMAZON_ISSUED")] // includes ACME-issued certificates + [InlineData("PRIVATE")] // AWS Private CA + public void CertificatesAcmIssuedItself_AreNotPrivateKeyEntries(string type) + { + var inv = new TestableInventory(AcmMocks.Client().Object, _output); + + var item = inv.Build(Arn, CertificateType.FindValue(type)); + + Assert.False(item.PrivateKeyEntry); + } + + [Fact] + public void AbsentType_IsTreatedAsNotImported() + { + // ConstantClass equality is null-safe and yields false, so an absent Type suppresses the + // renewal rather than offering one that always fails. + var inv = new TestableInventory(AcmMocks.Client().Object, _output); + + var item = inv.Build(Arn, null); + + Assert.False(item.PrivateKeyEntry); + } + + [Fact] + public void UnrecognisedFutureType_IsTreatedAsNotImported() + { + // CertificateType is a ConstantClass, so AWS can introduce a value this build has never + // heard of. It must not be mistaken for IMPORTED. + var inv = new TestableInventory(AcmMocks.Client().Object, _output); + + var item = inv.Build(Arn, CertificateType.FindValue("SOME_FUTURE_TYPE")); + + Assert.False(item.PrivateKeyEntry); + } + + // --------------------------------------------------------------------------------- + // Item shape + // --------------------------------------------------------------------------------- + + [Fact] + public void Alias_IsTheFullAcmArn() + { + // Management parses the alias back as an ARN to decide replace-vs-new, so the alias must + // stay the unmodified ARN. + var inv = new TestableInventory(AcmMocks.Client().Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + Assert.Equal(Arn, item.Alias); + Assert.True(Management.IsAcmCertificateArn(item.Alias)); + } + + [Fact] + public void ItemStatus_IsUnknownSoCommandComputesTheDiff() + { + var inv = new TestableInventory(AcmMocks.Client().Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + Assert.Equal(Keyfactor.Orchestrators.Common.Enums.OrchestratorInventoryItemStatus.Unknown, item.ItemStatus); + Assert.False(item.UseChainLevel); + } + + [Fact] + public void Certificate_IsSentAsBareBase64WithPemAnchorsRemoved() + { + var inv = new TestableInventory(AcmMocks.Client().Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + string body = Assert.Single(item.Certificates); + Assert.Equal(AcmMocks.SampleBase64, body); + Assert.DoesNotContain("BEGIN CERTIFICATE", body); + Assert.DoesNotContain("END CERTIFICATE", body); + } + + [Fact] + public void CarriageReturnsAreStrippedSoCrlfPemIsHandled() + { + var crlf = "-----BEGIN CERTIFICATE-----\r\nQUJD\r\n-----END CERTIFICATE-----\r\n"; + var inv = new TestableInventory(AcmMocks.Client(crlf).Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + Assert.Equal(AcmMocks.SampleBase64, Assert.Single(item.Certificates)); + } + + [Fact] + public void InteriorLineBreaksInTheBase64BodyArePreserved() + { + // Real PEM bodies wrap at 64 characters. Only the leading/trailing whitespace is trimmed - + // interior wrapping is left in place, which Command tolerates because Base64 decoding skips + // whitespace. Keeping it means the emitted value is unchanged from previous releases. + var wrapped = "-----BEGIN CERTIFICATE-----\nQUJD\nREVG\n-----END CERTIFICATE-----\n"; + var inv = new TestableInventory(AcmMocks.Client(wrapped).Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + Assert.Equal("QUJD\nREVG", Assert.Single(item.Certificates)); + } + + [Theory] + [InlineData("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----")] // no trailing newline + [InlineData("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n\n")] // extra trailing newline + [InlineData("-----BEGIN CERTIFICATE-----QUJD-----END CERTIFICATE-----")] // no newlines at all + [InlineData("\n-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n")] // leading newline + public void AnchorsAreStrippedRegardlessOfSurroundingNewlines(string pem) + { + // The stripping used to fold the newlines into the delimiter match, so the footer survived + // unless a newline followed it - shipping literal "-----END CERTIFICATE-----" to Command + // inside the Base64 body. ACM always sends that newline, so this never fired in production, + // but nothing guaranteed it would keep doing so. + var inv = new TestableInventory(AcmMocks.Client(pem).Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + Assert.Equal(AcmMocks.SampleBase64, Assert.Single(item.Certificates)); + } + + [Fact] + public void ARealCertificateRoundTripsThroughTheInventoryPath() + { + // The contract that actually matters: whatever Inventory puts in Certificates[] must decode + // back to the DER that ACM returned. Uses a genuine certificate with a full-width, + // 64-character-wrapped Base64 body rather than the short "QUJD" fixture. + var cert = CertificateFactory.SelfSignedCa("roundtrip.example.com").Certificate; + string pem = "-----BEGIN CERTIFICATE-----\n" + + Management._pemify(Convert.ToBase64String(cert.GetEncoded())) + + "\n-----END CERTIFICATE-----\n"; + var inv = new TestableInventory(AcmMocks.Client(pem).Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + string body = Assert.Single(item.Certificates); + Assert.DoesNotContain("CERTIFICATE-----", body); + Assert.Equal(cert.GetEncoded(), Convert.FromBase64String(body)); + } + + // --------------------------------------------------------------------------------- + // ACM Tags entry parameter + // --------------------------------------------------------------------------------- + + [Fact] + public void NoTags_YieldsAnEmptyAcmTagsParameter() + { + var inv = new TestableInventory(AcmMocks.Client().Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + Assert.Equal(string.Empty, item.Parameters["ACM Tags"]); + } + + [Fact] + public void SingleTag_HasNoLeadingSeparator() + { + var inv = new TestableInventory(AcmMocks.Client(tags: Tags(("purpose", "test"))).Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + Assert.Equal("purpose=test", item.Parameters["ACM Tags"]); + } + + [Fact] + public void MultipleTags_AreCommaSeparatedInAcmOrder() + { + var inv = new TestableInventory( + AcmMocks.Client(tags: Tags(("Name1", "Value1"), ("Name2", "Value2"))).Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + // Round-trips through the exact format Management.ParseACMTags consumes. + Assert.Equal("Name1=Value1,Name2=Value2", item.Parameters["ACM Tags"]); + } + + [Fact] + public void TagWithEmptyValue_StillEmitsTheEqualsSign() + { + var inv = new TestableInventory(AcmMocks.Client(tags: Tags(("flag", ""))).Object, _output); + + var item = inv.Build(Arn, CertificateType.IMPORTED); + + Assert.Equal("flag=", item.Parameters["ACM Tags"]); + } + + // --------------------------------------------------------------------------------- + // Failure handling + // --------------------------------------------------------------------------------- + + [Fact] + public void GetCertificateFailure_PropagatesSoTheBatchCanSkipTheItem() + { + // PerformInventory catches this per certificate, logs the ARN, raises the warning flag and + // carries on. The contract of BuildInventoryItem is to rethrow, not to swallow. + var mock = AcmMocks.Client(); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new ResourceNotFoundException("certificate is gone")); + + var inv = new TestableInventory(mock.Object, _output); + + var ex = Assert.ThrowsAny(() => inv.Build(Arn, CertificateType.IMPORTED)); + Assert.Contains("gone", ex.Message); + } + + [Fact] + public void ListTagsFailure_AlsoPropagates() + { + var mock = AcmMocks.Client(); + mock.Setup(c => c.ListTagsForCertificateAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidArnException("bad arn")); + + var inv = new TestableInventory(mock.Object, _output); + + Assert.ThrowsAny(() => inv.Build(Arn, CertificateType.IMPORTED)); + } + + [Fact] + public void ExactlyTwoApiCallsAreMadePerCertificate() + { + // The cost model is two calls per certificate on top of the paged ListCertificates, and + // deriving PrivateKeyEntry must not have added a DescribeCertificate. A regression here + // multiplies the AWS call volume of every inventory job. + var mock = AcmMocks.Client(); + var inv = new TestableInventory(mock.Object, _output); + + inv.Build(Arn, CertificateType.IMPORTED); + + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.ListTagsForCertificateAsync(It.IsAny(), It.IsAny()), Times.Once); + mock.Verify(c => c.DescribeCertificateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void TheArnIsPassedThroughToBothApiCalls() + { + var mock = AcmMocks.Client(); + var inv = new TestableInventory(mock.Object, _output); + + inv.Build(Arn, CertificateType.IMPORTED); + + mock.Verify(c => c.GetCertificateAsync( + It.Is(r => r.CertificateArn == Arn), It.IsAny()), Times.Once); + mock.Verify(c => c.ListTagsForCertificateAsync( + It.Is(r => r.CertificateArn == Arn), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------------------- + // StoreRef, the operator-facing store identifier embedded in every job message + // --------------------------------------------------------------------------------- + + [Fact] + public void StoreRef_NamesBothHalvesOfTheStoreIdentity() + { + string reference = Inventory.StoreRef("acmtest-defaultsdk-assume", "arn:aws:iam::1:role/r|us-east-2"); + + Assert.Equal( + "store [ClientMachine='acmtest-defaultsdk-assume', StorePath='arn:aws:iam::1:role/r|us-east-2']", + reference); + } + + [Fact] + public void StoreRef_RendersNullsWithoutThrowing() + { + // Failure messages must survive a half-configured store. + string reference = Inventory.StoreRef(null, null); + + Assert.Equal("store [ClientMachine='', StorePath='']", reference); + } + } +} diff --git a/aws-acm-orchestrator.Tests/Jobs/ManagementTests.cs b/aws-acm-orchestrator.Tests/Jobs/ManagementTests.cs new file mode 100644 index 0000000..fda0e18 --- /dev/null +++ b/aws-acm-orchestrator.Tests/Jobs/ManagementTests.cs @@ -0,0 +1,337 @@ +// 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.Orchestrator.Aws.Acm.Jobs; +using Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.TestUtilities; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.X509; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.Jobs +{ + public class ManagementTests + { + private readonly ITestOutputHelper _output; + + public ManagementTests(ITestOutputHelper output) => _output = output; + + // --------------------------------------------------------------------------------- + // Alias classification decides replace-in-place versus import-as-new. + // + // This replaced an alias-length heuristic that misclassified long friendly aliases, so the + // boundary cases matter: a false positive sends a DescribeCertificate for an ARN that does + // not exist, a false negative silently creates a duplicate certificate in ACM. + // --------------------------------------------------------------------------------- + + [Theory] + [InlineData("arn:aws:acm:us-east-2:905418207372:certificate/fac411f1-a478-4a8b-9680-eee1df74ca2d")] + [InlineData("arn:aws:acm:eu-west-1:123456789012:certificate/abc")] + [InlineData("ARN:AWS:ACM:us-east-1:1:certificate/x")] + [InlineData(" arn:aws:acm:us-east-1:1:certificate/x ")] + public void AcmCertificateArns_AreRecognised(string alias) + => Assert.True(Management.IsAcmCertificateArn(alias)); + + [Theory] + [InlineData("ARN:AWS:ACM:US-EAST-1:1:CERTIFICATE/X")] + [InlineData("arn:aws:acm:us-east-1:1:Certificate/X")] + [InlineData("Arn:Aws:Acm:us-east-1:1:CERTIFICATE/abc-123")] + public void ArnMatchingIsCaseInsensitiveThroughout(string alias) + { + // Both halves of the check are case-insensitive. The prefix test always was; the resource + // test was a plain ordinal Contains, so a fully uppercased ARN passed the first check and + // failed the second. That meant a hand-entered alias would be imported as a new certificate + // instead of replaced in place, leaving a duplicate in ACM. + Assert.True(Management.IsAcmCertificateArn(alias)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("my-friendly-alias")] + [InlineData("a-very-long-friendly-alias-that-the-old-length-heuristic-would-have-misread-as-an-arn")] + public void NonArnAliases_AreNotRecognised(string alias) + => Assert.False(Management.IsAcmCertificateArn(alias)); + + [Fact] + public void AnotherAwsServiceArn_IsNotAnAcmCertificateArn() + { + // Needs both the acm service prefix and the certificate resource segment. + Assert.False(Management.IsAcmCertificateArn("arn:aws:iam::905418207372:role/iam-ACM-access")); + Assert.False(Management.IsAcmCertificateArn("arn:aws:acm-pca:us-east-1:1:certificate-authority/x")); + } + + [Fact] + public void AcmArnWithoutACertificateResource_IsNotRecognised() + { + // An ACM ARN that is not a certificate (for example an ACME endpoint) must not be treated + // as a certificate to replace. + Assert.False(Management.IsAcmCertificateArn("arn:aws:acm:us-east-2:905418207372:acme-endpoint/d14a20d2")); + } + + [Fact] + public void GovCloudAndChinaPartitionArns_AreNotRecognised() + { + // Documents current behaviour rather than endorsing it: the check is hard-coded to the + // commercial "arn:aws:acm:" prefix, so a GovCloud or China certificate would be imported as + // new instead of replaced in place. Change this test if partition support is added. + Assert.False(Management.IsAcmCertificateArn("arn:aws-us-gov:acm:us-gov-west-1:1:certificate/x")); + Assert.False(Management.IsAcmCertificateArn("arn:aws-cn:acm:cn-north-1:1:certificate/x")); + } + + // --------------------------------------------------------------------------------- + // ACM Tags parsing - entry parameter format "Name1=Value1,Name2=Value2" + // --------------------------------------------------------------------------------- + + private static Dictionary Props(object value) + => new Dictionary { { "ACM Tags", value } }; + + [Fact] + public void NullJobProperties_YieldNoTags() + => Assert.Empty(TestableManagement.Create(_output).ParseACMTags(null)); + + [Fact] + public void MissingAcmTagsKey_YieldsNoTags() + => Assert.Empty(TestableManagement.Create(_output) + .ParseACMTags(new Dictionary { { "Something Else", "x" } })); + + [Fact] + public void NullAcmTagsValue_YieldsNoTags() + => Assert.Empty(TestableManagement.Create(_output).ParseACMTags(Props(null))); + + [Fact] + public void EmptyAcmTagsValue_YieldsNoTags() + => Assert.Empty(TestableManagement.Create(_output).ParseACMTags(Props(""))); + + [Fact] + public void SingleTag_IsParsed() + { + var tags = TestableManagement.Create(_output).ParseACMTags(Props("purpose=testing")); + + var tag = Assert.Single(tags); + Assert.Equal("purpose", tag.Key); + Assert.Equal("testing", tag.Value); + } + + [Fact] + public void MultipleTags_AreParsedInOrder() + { + var tags = TestableManagement.Create(_output).ParseACMTags(Props("Name1=Value1,Name2=Value2")); + + Assert.Equal(2, tags.Count); + Assert.Equal(new[] { "Name1", "Name2" }, tags.Select(t => t.Key)); + Assert.Equal(new[] { "Value1", "Value2" }, tags.Select(t => t.Value)); + } + + [Fact] + public void WhitespaceAroundTagsAndSeparators_IsTrimmed() + { + var tags = TestableManagement.Create(_output).ParseACMTags(Props(" Name1 = Value1 , Name2 = Value2 ")); + + Assert.Equal(2, tags.Count); + Assert.Equal("Name1", tags[0].Key); + Assert.Equal("Value1", tags[0].Value); + Assert.Equal("Name2", tags[1].Key); + } + + [Fact] + public void EmptyEntriesBetweenSeparators_AreSkipped() + { + var tags = TestableManagement.Create(_output).ParseACMTags(Props("a=1,,b=2,")); + + Assert.Equal(2, tags.Count); + } + + [Fact] + public void TagsAreRoundTrippedFromTheFormatInventoryEmits() + { + // Inventory writes the ACM Tags entry parameter and Management reads it back. The two + // formats must agree or tags silently change on every replace. + var tags = TestableManagement.Create(_output).ParseACMTags(Props("Name1=Value1,Name2=Value2")); + + string roundTripped = string.Join(",", tags.Select(t => $"{t.Key}={t.Value}")); + Assert.Equal("Name1=Value1,Name2=Value2", roundTripped); + } + + [Theory] + [InlineData("justakey")] // no separator at all + [InlineData("a=1=2")] // three segments + [InlineData("=orphanvalue")] // empty key is dropped by RemoveEmptyEntries, leaving one segment + [InlineData("orphankey=")] // empty value is dropped the same way + public void MalformedTags_ThrowWithAnExplanatoryMessage(string value) + { + var ex = Assert.Throws(() => TestableManagement.Create(_output).ParseACMTags(Props(value))); + + Assert.Contains("invalid format", ex.Message); + Assert.Contains("instead of 2", ex.Message); + } + + [Fact] + public void NonStringAcmTagsValue_IsStringifiedBeforeParsing() + { + // JobProperties is Dictionary, so the value need not already be a string. + var tags = TestableManagement.Create(_output).ParseACMTags(Props((object)"env=prod")); + + Assert.Equal("env", Assert.Single(tags).Key); + } + + // --------------------------------------------------------------------------------- + // PEM line wrapping + // --------------------------------------------------------------------------------- + + [Fact] + public void Pemify_LeavesShortInputUntouched() + { + Assert.Equal("ABC", Management._pemify("ABC")); + } + + [Fact] + public void Pemify_WrapsAtExactly64Characters() + { + string body = new string('A', 64); + + Assert.Equal(body, Management._pemify(body)); + } + + [Fact] + public void Pemify_BreaksLongInputIntoSixtyFourCharacterLines() + { + string body = new string('A', 150); + + string[] lines = Management._pemify(body).Split('\n'); + + Assert.Equal(new[] { 64, 64, 22 }, lines.Select(l => l.Length)); + // Wrapping must be lossless - the certificate body is reassembled by the consumer. + Assert.Equal(body, string.Concat(lines)); + } + + [Fact] + public void Pemify_HandlesAnExactMultipleOf64WithoutATrailingBlankLine() + { + string body = new string('B', 128); + + string[] lines = Management._pemify(body).Split('\n'); + + Assert.Equal(2, lines.Length); + Assert.All(lines, l => Assert.Equal(64, l.Length)); + } + + // --------------------------------------------------------------------------------- + // Certificate chain extraction from the uploaded PFX + // + // BouncyCastle returns the chain leaf-first. The leaf is sent separately as the Certificate + // body of the ImportCertificateRequest, so repeating it in CertificateChain made it appear + // twice in the published chain - the regression fixed in 3.0.x. These tests pin the leaf out + // of the chain so it cannot come back. + // --------------------------------------------------------------------------------- + + private const string Alias = "test-entry"; + private const string BeginMarker = "-----BEGIN CERTIFICATE-----\n"; + private const string EndMarker = "\n-----END CERTIFICATE-----"; + + private static string ChainPem(Pkcs12Store store, string alias = Alias) + { + using MemoryStream stream = Management.GetChain(store, alias); + return stream == null ? null : Encoding.ASCII.GetString(stream.ToArray()); + } + + private static string[] PemBlocks(string pem) + => pem.Split(new[] { BeginMarker }, StringSplitOptions.RemoveEmptyEntries) + .Select(b => b.Replace(EndMarker, "").Replace("\n", "").Trim()) + .Where(b => b.Length > 0) + .ToArray(); + + private static string Base64Of(Org.BouncyCastle.X509.X509Certificate cert) + => Convert.ToBase64String(cert.GetEncoded()); + + [Fact] + public void SelfSignedCertificateWithNoIntermediates_SendsNoChainAtAll() + { + // A chain of just the leaf must yield null, not an empty stream: ACM rejects an empty + // CertificateChain as unparseable, so the field has to be omitted entirely. + Assert.Null(ChainPem(CertificateFactory.StoreWithChain(Alias, chainLength: 1))); + } + + [Fact] + public void AliasWithNoChain_SendsNoChain() + => Assert.Null(ChainPem(CertificateFactory.StoreWithNoChainForAlias(Alias))); + + [Fact] + public void LeafPlusRoot_SendsOnlyTheRoot() + { + Pkcs12Store store = CertificateFactory.StoreWithChain(Alias, chainLength: 2); + X509CertificateEntry[] chain = store.GetCertificateChain(Alias); + + string[] blocks = PemBlocks(ChainPem(store)); + + string only = Assert.Single(blocks); + Assert.Equal(Base64Of(chain[1].Certificate), only); + // The explicit regression assertion: the leaf must not appear. + Assert.DoesNotContain(Base64Of(chain[0].Certificate), only); + } + + [Fact] + public void LeafPlusIntermediatePlusRoot_SendsBothCasNearestFirst() + { + Pkcs12Store store = CertificateFactory.StoreWithChain(Alias, chainLength: 3); + X509CertificateEntry[] chain = store.GetCertificateChain(Alias); + Assert.Equal(3, chain.Length); // guards the fixture itself + + string[] blocks = PemBlocks(ChainPem(store)); + + Assert.Equal(2, blocks.Length); + Assert.Equal(Base64Of(chain[1].Certificate), blocks[0]); + Assert.Equal(Base64Of(chain[2].Certificate), blocks[1]); + Assert.DoesNotContain(Base64Of(chain[0].Certificate), string.Concat(blocks)); + } + + [Fact] + public void ChainBlocksAreWrappedAtSixtyFourCharactersAndReParse() + { + string pem = ChainPem(CertificateFactory.StoreWithChain(Alias, chainLength: 3)); + + Assert.Equal(2, Regex.Matches(pem, "-----BEGIN CERTIFICATE-----").Count); + Assert.Equal(2, Regex.Matches(pem, "-----END CERTIFICATE-----").Count); + + // Every body line except the last of each block is exactly 64 characters, and the whole + // thing is still parseable as X.509 - the real contract with ACM. + foreach (string[] lines in pem.Split(new[] { BeginMarker }, StringSplitOptions.RemoveEmptyEntries) + .Select(b => b.Replace(EndMarker, "").Trim('\n').Split('\n')) + .Where(l => l.Length > 1)) + { + Assert.All(lines.Take(lines.Length - 1), l => Assert.Equal(64, l.Length)); + Assert.InRange(lines[lines.Length - 1].Length, 1, 64); + } + + var parser = new Org.BouncyCastle.X509.X509CertificateParser(); + Assert.Equal(2, parser.ReadCertificates(Encoding.ASCII.GetBytes(pem)).Count); + } + + [Fact] + public void ChainIsAsciiEncoded() + { + // CertStringToStream uses Encoding.ASCII. Base64 and the delimiters are all ASCII, so this + // is lossless - but it would silently corrupt anything that was not. + string pem = ChainPem(CertificateFactory.StoreWithChain(Alias, chainLength: 2)); + + Assert.All(pem, c => Assert.True(c < 128, $"non-ASCII character U+{(int)c:X4} in chain PEM")); + } + } +} diff --git a/aws-acm-orchestrator.Tests/Jobs/StorePathParserTests.cs b/aws-acm-orchestrator.Tests/Jobs/StorePathParserTests.cs new file mode 100644 index 0000000..ca935c6 --- /dev/null +++ b/aws-acm-orchestrator.Tests/Jobs/StorePathParserTests.cs @@ -0,0 +1,120 @@ +// 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.Orchestrator.Aws.Acm.Jobs; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.Jobs +{ + /// + /// A store's identity is Role ARN + Region. Legacy stores carry the region in StorePath and the ARN + /// in ClientMachine; stores emitted by cross-account Discovery fold both into "<roleArn>|<region>". + /// Both shapes must resolve through one code path (v3.1.0). + /// + public class StorePathParserTests + { + private const string RoleArn = "arn:aws:iam::905418207372:role/iam-ACM-access"; + + [Fact] + public void CombinedPath_TakesBothValuesFromStorePath() + { + var (role, region) = StorePathParser.Parse($"{RoleArn}|us-east-2", "ignored-client-machine"); + + Assert.Equal(RoleArn, role); + Assert.Equal("us-east-2", region); + } + + [Fact] + public void LegacyPath_TakesRegionFromStorePathAndRoleFromClientMachine() + { + var (role, region) = StorePathParser.Parse("us-east-1", RoleArn); + + Assert.Equal(RoleArn, role); + Assert.Equal("us-east-1", region); + } + + [Fact] + public void CombinedPath_TrimsWhitespaceAroundBothSegments() + { + var (role, region) = StorePathParser.Parse($" {RoleArn} | us-west-2 ", null); + + Assert.Equal(RoleArn, role); + Assert.Equal("us-west-2", region); + } + + [Fact] + public void CombinedPath_WithEmptyRoleSegment_FallsBackToClientMachine() + { + // Documented behaviour: a hand-edited "|us-east-1" still works. + var (role, region) = StorePathParser.Parse("|us-east-1", RoleArn); + + Assert.Equal(RoleArn, role); + Assert.Equal("us-east-1", region); + } + + [Fact] + public void CombinedPath_SplitsOnlyOnTheFirstSeparator() + { + // Split is bounded to 2 parts, so a stray separator stays inside the region segment rather + // than silently truncating it. + var (role, region) = StorePathParser.Parse($"{RoleArn}|us-east-2|extra", null); + + Assert.Equal(RoleArn, role); + Assert.Equal("us-east-2|extra", region); + } + + [Fact] + public void CombinedPath_WithNoRegionSegment_YieldsEmptyRegion() + { + var (role, region) = StorePathParser.Parse($"{RoleArn}|", "cm"); + + Assert.Equal(RoleArn, role); + Assert.Equal(string.Empty, region); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void BlankStorePath_FallsThroughToLegacyShape(string storePath) + { + var (role, region) = StorePathParser.Parse(storePath, RoleArn); + + Assert.Equal(RoleArn, role); + // Legacy shape trims, and a null StorePath stays null rather than becoming "". + Assert.Equal(storePath?.Trim(), region); + } + + [Fact] + public void Build_ProducesAPathThatParseRoundTrips() + { + string built = StorePathParser.Build(RoleArn, "eu-west-1"); + + Assert.Equal($"{RoleArn}|eu-west-1", built); + + var (role, region) = StorePathParser.Parse(built, null); + Assert.Equal(RoleArn, role); + Assert.Equal("eu-west-1", region); + } + + [Fact] + public void Separator_IsAbsentFromArnsAndRegionNames() + { + // The separator choice is what makes the combined path unambiguous. If '|' ever became + // legal in an ARN or region name this assumption would silently break. + Assert.DoesNotContain(StorePathParser.Separator.ToString(), RoleArn); + Assert.DoesNotContain(StorePathParser.Separator.ToString(), "us-east-2"); + } + } +} diff --git a/aws-acm-orchestrator.Tests/TestUtilities/AcmMocks.cs b/aws-acm-orchestrator.Tests/TestUtilities/AcmMocks.cs new file mode 100644 index 0000000..b641fba --- /dev/null +++ b/aws-acm-orchestrator.Tests/TestUtilities/AcmMocks.cs @@ -0,0 +1,93 @@ +// 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 Amazon.CertificateManager; +using Amazon.CertificateManager.Model; +using Moq; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.TestUtilities +{ + /// + /// Builders for a fake . The job classes hold their client + /// in an internal field of the interface type, so a test can substitute one and exercise the real + /// job logic without reaching AWS. + /// + internal static class AcmMocks + { + /// A syntactically valid PEM whose body is the Base64 of "ABC". + public const string SamplePem = "-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n"; + + public const string SampleBase64 = "QUJD"; + + public static Mock Client( + string certificatePem = SamplePem, + IEnumerable> tags = null) + { + var mock = new Mock(MockBehavior.Loose); + + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new GetCertificateResponse { Certificate = certificatePem }); + + var tagList = (tags ?? Enumerable.Empty>()) + .Select(kv => new Amazon.CertificateManager.Model.Tag { Key = kv.Key, Value = kv.Value }) + .ToList(); + + mock.Setup(c => c.ListTagsForCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ListTagsForCertificateResponse { Tags = tagList }); + + return mock; + } + + /// + /// Returns the supplied pages from ListCertificates in order, wiring NextToken so the job's + /// pagination loop terminates after the final page. + /// + public static Mock PagingClient(params List[] pages) + { + var mock = Client(); + var queue = new Queue(); + for (int i = 0; i < pages.Length; i++) + { + queue.Enqueue(new ListCertificatesResponse + { + CertificateSummaryList = pages[i], + NextToken = i == pages.Length - 1 ? null : $"token-{i}" + }); + } + + mock.Setup(c => c.ListCertificatesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => queue.Count > 0 + ? queue.Dequeue() + : new ListCertificatesResponse { CertificateSummaryList = new List(), NextToken = null }); + + return mock; + } + + public static CertificateSummary Summary(string arn, CertificateType type = null, + CertificateKeyPairOrigin origin = null, string domain = "example.com") + { + return new CertificateSummary + { + CertificateArn = arn, + DomainName = domain, + Type = type, + CertificateKeyPairOrigin = origin + }; + } + } +} diff --git a/aws-acm-orchestrator.Tests/TestUtilities/CertificateFactory.cs b/aws-acm-orchestrator.Tests/TestUtilities/CertificateFactory.cs new file mode 100644 index 0000000..f6bf559 --- /dev/null +++ b/aws-acm-orchestrator.Tests/TestUtilities/CertificateFactory.cs @@ -0,0 +1,138 @@ +// 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 Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using System; +using System.Collections.Generic; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.TestUtilities +{ + /// + /// Builds throwaway certificates and PKCS#12 stores with BouncyCastle, so the chain-splitting + /// logic can be tested against the same object model the Management job receives at run time. + /// + /// Keys are deliberately 1024-bit: these certificates are never trusted, never leave the test + /// process, and never reach AWS. The only requirement is that they parse and that the issuer + /// links resolve, so key strength buys nothing but test latency. + /// + internal static class CertificateFactory + { + private static readonly SecureRandom Random = new SecureRandom(); + + internal sealed class Issued + { + public X509Certificate Certificate { get; set; } + public AsymmetricKeyParameter PrivateKey { get; set; } + } + + internal static AsymmetricCipherKeyPair NewKeyPair() + { + var generator = new RsaKeyPairGenerator(); + generator.Init(new KeyGenerationParameters(Random, 1024)); + return generator.GenerateKeyPair(); + } + + /// Self-signed CA, used as the root of a test chain. + internal static Issued SelfSignedCa(string commonName) + => Issue(commonName, new X509Name($"CN={commonName}"), null, isCa: true); + + /// Certificate signed by . + internal static Issued IssuedBy(string commonName, Issued issuer, bool isCa = false) + => Issue(commonName, issuer.Certificate.SubjectDN, issuer, isCa); + + private static Issued Issue(string commonName, X509Name issuerDn, Issued issuer, bool isCa) + { + AsymmetricCipherKeyPair subjectKeys = NewKeyPair(); + AsymmetricKeyParameter signingKey = issuer == null ? subjectKeys.Private : issuer.PrivateKey; + + var generator = new X509V3CertificateGenerator(); + generator.SetSerialNumber(BigInteger.ProbablePrime(64, Random)); + generator.SetIssuerDN(issuerDn); + generator.SetSubjectDN(new X509Name($"CN={commonName}")); + generator.SetNotBefore(DateTime.UtcNow.AddDays(-1)); + generator.SetNotAfter(DateTime.UtcNow.AddDays(30)); + generator.SetPublicKey(subjectKeys.Public); + generator.AddExtension(X509Extensions.BasicConstraints, true, new BasicConstraints(isCa)); + + return new Issued + { + Certificate = generator.Generate(new Asn1SignatureFactory("SHA256WithRSA", signingKey, Random)), + PrivateKey = subjectKeys.Private + }; + } + + /// + /// A PKCS#12 store holding one key entry whose chain runs leaf -> (intermediates) -> root. + /// counts the leaf, so 1 is a bare self-signed certificate, + /// 2 is leaf + root, and 3 is leaf + intermediate + root. + /// + internal static Pkcs12Store StoreWithChain(string alias, int chainLength) + { + if (chainLength < 1) throw new ArgumentOutOfRangeException(nameof(chainLength)); + + var chain = new List(); + + if (chainLength == 1) + { + // Self-signed: the leaf is its own issuer, so the chain walk terminates immediately. + chain.Add(SelfSignedCa("self-signed.example.com")); + } + else + { + Issued root = SelfSignedCa("Test Root CA"); + Issued signer = root; + var cas = new List { root }; + + for (int i = 0; i < chainLength - 2; i++) + { + signer = IssuedBy($"Test Intermediate CA {i + 1}", signer, isCa: true); + cas.Add(signer); + } + + chain.Add(IssuedBy("leaf.example.com", signer)); + // Leaf first, then issuers from nearest to furthest - the order BouncyCastle returns. + cas.Reverse(); + chain.AddRange(cas); + } + + var store = new Pkcs12StoreBuilder().Build(); + var entries = new X509CertificateEntry[chain.Count]; + for (int i = 0; i < chain.Count; i++) + entries[i] = new X509CertificateEntry(chain[i].Certificate); + + store.SetKeyEntry(alias, new AsymmetricKeyEntry(chain[0].PrivateKey), entries); + return store; + } + + /// A store whose key entry has no certificate chain attached at all. + internal static Pkcs12Store StoreWithNoChainForAlias(string alias) + { + var store = new Pkcs12StoreBuilder().Build(); + Issued leaf = SelfSignedCa("orphan.example.com"); + + // Deliberately register the cert under a *different* alias so the requested alias resolves + // to nothing and GetCertificateChain returns null. + store.SetCertificateEntry(alias + "-other", new X509CertificateEntry(leaf.Certificate)); + return store; + } + } +} diff --git a/aws-acm-orchestrator.Tests/TestUtilities/TestableJobs.cs b/aws-acm-orchestrator.Tests/TestUtilities/TestableJobs.cs new file mode 100644 index 0000000..0e8759d --- /dev/null +++ b/aws-acm-orchestrator.Tests/TestUtilities/TestableJobs.cs @@ -0,0 +1,75 @@ +// 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 Amazon.CertificateManager; +using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.TestUtilities +{ + /// + /// Exposes (protected virtual) and lets a test inject a + /// fake ACM client into the internal AcmClient field. + /// + /// Note the asymmetry: BuildInventoryItem reads AcmClient, but PerformInventory *reassigns* it to a + /// real AmazonCertificateManagerClient on entry. So item-level behaviour is unit-testable while + /// PerformInventory as a whole is not - see FilterParityTests for what that costs. + /// + internal sealed class TestableInventory : Inventory + { + public TestableInventory(IAmazonCertificateManager acm, ITestOutputHelper output = null) + : base(new Mock().Object, NullLoggerOf(output)) + { + if (output != null) Logger = new XunitLogger(nameof(Inventory), output); + AcmClient = acm; + } + + public CurrentInventoryItem Build(string alias, CertificateType certificateType) + => BuildInventoryItem(alias, certificateType); + + private static ILogger NullLoggerOf(ITestOutputHelper output) + => output == null + ? (ILogger)Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance + : new XunitLogger(output); + } + + /// Constructs Management without touching AWS, for its pure helpers. + internal sealed class TestableManagement + { + public static Management Create(ITestOutputHelper output = null) + { + var mgmt = new Management(new Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + if (output != null) mgmt.Logger = new XunitLogger(nameof(Management), output); + return mgmt; + } + } + + /// Constructs Discovery without touching AWS, for its pure helpers. + internal sealed class TestableDiscovery + { + public static Discovery Create(ITestOutputHelper output = null) + { + var disc = new Discovery(new Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + if (output != null) disc.Logger = new XunitLogger(nameof(Discovery), output); + return disc; + } + } +} diff --git a/aws-acm-orchestrator.Tests/TestUtilities/XunitLogger.cs b/aws-acm-orchestrator.Tests/TestUtilities/XunitLogger.cs new file mode 100644 index 0000000..1ccdb1e --- /dev/null +++ b/aws-acm-orchestrator.Tests/TestUtilities/XunitLogger.cs @@ -0,0 +1,67 @@ +// 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 Microsoft.Extensions.Logging; +using System; +using Xunit.Abstractions; + +namespace Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests.TestUtilities +{ + /// + /// Routes extension log output into the xUnit test output so a failing assertion can be read + /// alongside the Debug/Trace lines the job emitted. Mirrors the pattern used by the Keyfactor + /// sample orchestrator extension's unit tests. + /// + public class XunitLogger : ILogger + { + private readonly ITestOutputHelper _output; + private readonly string _category; + + public XunitLogger(string category, ITestOutputHelper output) + { + _category = category; + _output = output; + } + + public IDisposable BeginScope(TState state) => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + // ITestOutputHelper throws if written to after the test has completed (a job that logs + // from a background continuation, for example). Swallow that rather than failing an + // otherwise-passing test on a logging side effect. + try + { + _output.WriteLine($"[{logLevel}] {_category}: {formatter(state, exception)}"); + if (exception != null) _output.WriteLine(exception.ToString()); + } + catch (InvalidOperationException) { } + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new NullScope(); + public void Dispose() { } + } + } + + public class XunitLogger : XunitLogger, ILogger + { + public XunitLogger(ITestOutputHelper output) : base(typeof(T).Name, output) { } + } +} diff --git a/aws-acm-orchestrator.Tests/aws-acm-orchestrator.Tests.csproj b/aws-acm-orchestrator.Tests/aws-acm-orchestrator.Tests.csproj index a0f2754..8512db3 100644 --- a/aws-acm-orchestrator.Tests/aws-acm-orchestrator.Tests.csproj +++ b/aws-acm-orchestrator.Tests/aws-acm-orchestrator.Tests.csproj @@ -1,25 +1,25 @@ - net8.0 - disable + net8.0;net10.0 + enable disable false + true Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests - - - + + + - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - + + + + + diff --git a/aws-acm-orchestrator.sln b/aws-acm-orchestrator.sln index 9629a29..b497a7f 100644 --- a/aws-acm-orchestrator.sln +++ b/aws-acm-orchestrator.sln @@ -1,13 +1,14 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.8.12023.21 stable +VisualStudioVersion = 18.8.12023.21 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aws-acm-orchestrator", "aws-acm-orchestrator\aws-acm-orchestrator.csproj", "{9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{515E06CB-9355-43EA-AF70-27F066A5C3AF}" ProjectSection(SolutionItems) = preProject docsource\aws-acm-v3.md = docsource\aws-acm-v3.md + CHANGELOG.md = CHANGELOG.md docsource\content.md = docsource\content.md integration-manifest.json = integration-manifest.json readme_source.md = readme_source.md @@ -37,16 +38,42 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Images", "Images", "{7F05A2 Images\UserAccount.gif = Images\UserAccount.gif EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "aws-acm-orchestrator.tests", "aws-acm-orchestrator.tests\aws-acm-orchestrator.tests.csproj", "{F9AD9F55-B432-4772-931A-0EAE78612BB1}" +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 {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Debug|x64.ActiveCfg = Debug|Any CPU + {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Debug|x64.Build.0 = Debug|Any CPU + {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Debug|x86.ActiveCfg = Debug|Any CPU + {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Debug|x86.Build.0 = Debug|Any CPU {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Release|Any CPU.ActiveCfg = Release|Any CPU {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Release|Any CPU.Build.0 = Release|Any CPU + {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Release|x64.ActiveCfg = Release|Any CPU + {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Release|x64.Build.0 = Release|Any CPU + {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Release|x86.ActiveCfg = Release|Any CPU + {9BEDC094-06DC-40C6-B5DF-88B4960D4DCF}.Release|x86.Build.0 = Release|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Debug|x64.ActiveCfg = Debug|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Debug|x64.Build.0 = Debug|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Debug|x86.ActiveCfg = Debug|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Debug|x86.Build.0 = Debug|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Release|Any CPU.Build.0 = Release|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Release|x64.ActiveCfg = Release|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Release|x64.Build.0 = Release|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Release|x86.ActiveCfg = Release|Any CPU + {F9AD9F55-B432-4772-931A-0EAE78612BB1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/aws-acm-orchestrator/AssemblyInfo.cs b/aws-acm-orchestrator/AssemblyInfo.cs new file mode 100644 index 0000000..140d49e --- /dev/null +++ b/aws-acm-orchestrator/AssemblyInfo.cs @@ -0,0 +1,24 @@ +// 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.Runtime.CompilerServices; + +// The job classes expose their collaborators (AcmClient, Logger) and their pure helpers as +// 'internal' precisely so they can be exercised without reaching AWS. Grant the unit test +// assembly access rather than widening anything to public. +[assembly: InternalsVisibleTo("Keyfactor.Extensions.Orchestrator.Aws.Acm.Tests")] + +// Moq generates proxies for internal interfaces (e.g. IAmazonCertificateManager members) in a +// dynamic assembly, which needs the same grant. +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/aws-acm-orchestrator/Jobs/Discovery.cs b/aws-acm-orchestrator/Jobs/Discovery.cs index 1549d1f..23730aa 100644 --- a/aws-acm-orchestrator/Jobs/Discovery.cs +++ b/aws-acm-orchestrator/Jobs/Discovery.cs @@ -345,7 +345,37 @@ private bool RegionHasCertificates(AWSCredentials credentials, RegionEndpoint re { Logger.LogDebug($"Checking for ACM certificates in region: {region.SystemName}"); using var acm = new AmazonCertificateManagerClient(credentials, region); - var request = new ListCertificatesRequest { MaxItems = 1 }; + + // Both of these are inclusion filters: anything not named is silently absent from the result. + // This probe decides whether a region gets a certificate store at all, so filtering more + // narrowly than Inventory makes a region full of certificates look empty and no store is ever + // created for it. Keep both lists in sync with Inventory.PerformInventory - FilterParityTests + // fails the build if they drift. + // - Origins: ACM omits ACME-origin certificates unless the origins are requested explicitly. + // - KeyTypes: works around ACM not returning certificates that have no SAN. + var request = new ListCertificatesRequest + { + MaxItems = 1, + CertificateKeyPairOrigins = new List + { + CertificateKeyPairOrigin.AWS_MANAGED, + CertificateKeyPairOrigin.CUSTOMER_PROVIDED, + CertificateKeyPairOrigin.ACME + }, + Includes = new Filters + { + KeyTypes = new List + { + KeyAlgorithm.RSA_1024, + KeyAlgorithm.RSA_2048, + KeyAlgorithm.RSA_3072, + KeyAlgorithm.RSA_4096, + KeyAlgorithm.EC_prime256v1, + KeyAlgorithm.EC_secp384r1, + KeyAlgorithm.EC_secp521r1 + } + } + }; var response = AsyncHelpers.RunSync(() => acm.ListCertificatesAsync(request)); return response.CertificateSummaryList.Count > 0; } @@ -406,7 +436,7 @@ private AWSCredentials AssumeRole(AWSCredentials baseCredentials, RegionEndpoint } // Builds arn:aws:iam:::role/, partition-aware for GovCloud/China. - private static string BuildMemberRoleArn(string partitionName, string accountId, string roleName) + internal static string BuildMemberRoleArn(string partitionName, string accountId, string roleName) { string partition; switch (partitionName) @@ -419,7 +449,7 @@ private static string BuildMemberRoleArn(string partitionName, string accountId, } // AWS Organizations has a single global endpoint per partition. - private static RegionEndpoint GetOrganizationsRegion(string partitionName) + internal static RegionEndpoint GetOrganizationsRegion(string partitionName) { switch (partitionName) { @@ -464,7 +494,7 @@ private HashSet GetEnabledRegions(AWSCredentials credentials, RegionEndp } } - private List ParseRegions(string regionsConfig, string partitionName) + internal List ParseRegions(string regionsConfig, string partitionName) { // Treat blank or "*" (spec's wildcard for "all enabled regions") as "use the full partition list". if (string.IsNullOrWhiteSpace(regionsConfig) || regionsConfig.Trim() == "*") @@ -477,7 +507,7 @@ private List ParseRegions(string regionsConfig, string partition .ToList(); } - private static IEnumerable SplitCsv(string value) + internal static IEnumerable SplitCsv(string value) { if (string.IsNullOrWhiteSpace(value)) return Enumerable.Empty(); @@ -488,12 +518,12 @@ private static IEnumerable SplitCsv(string value) .Where(s => s.Length > 0); } - private static string FirstNonEmpty(string a, string b) + internal static string FirstNonEmpty(string a, string b) { return string.IsNullOrWhiteSpace(a) ? b : a; } - private string GetJobProperty(Dictionary jobProperties, string key) + internal string GetJobProperty(Dictionary jobProperties, string key) { if (jobProperties != null && jobProperties.TryGetValue(key, out object value) && value != null) return value.ToString(); @@ -503,7 +533,7 @@ private string GetJobProperty(Dictionary jobProperties, string k // Case-insensitive lookup across several candidate keys (returns the first non-empty match). // Lets a single concept be supplied either via an explicit programmatic property or via the // standard Discovery dialog field whose JobProperties key varies by Command version. - private string GetJobPropertyMulti(Dictionary jobProperties, params string[] keys) + internal string GetJobPropertyMulti(Dictionary jobProperties, params string[] keys) { if (jobProperties == null) return null; foreach (var key in keys) diff --git a/aws-acm-orchestrator/Jobs/Inventory.cs b/aws-acm-orchestrator/Jobs/Inventory.cs index 22ce7f7..1d9aa17 100644 --- a/aws-acm-orchestrator/Jobs/Inventory.cs +++ b/aws-acm-orchestrator/Jobs/Inventory.cs @@ -112,37 +112,58 @@ internal JobResult PerformInventory(AwsExtensionCredential awsCredentials, Inven Logger.LogTrace("ACM client created with loaded AWS Credentials and specified Region."); - var certList = AsyncHelpers.RunSync(() => AcmClient.ListCertificatesAsync()); - Logger.LogDebug($"Found {certList.CertificateSummaryList.Count} Certificates"); - Logger.LogTrace($"Cert List JSON: \n{JsonConvert.SerializeObject(certList)}"); - ListCertificatesRequest req = new ListCertificatesRequest(); //The Current Workaround For AWS Not Returning Certs Without A SAN - List keyTypes = new List { KeyAlgorithm.RSA_1024, KeyAlgorithm.RSA_2048, KeyAlgorithm.RSA_4096, KeyAlgorithm.EC_prime256v1, KeyAlgorithm.EC_secp384r1, KeyAlgorithm.EC_secp521r1 }; + //Includes.KeyTypes is an inclusion filter, so every KeyAlgorithm the SDK defines must be + //listed here - any algorithm omitted is silently absent from inventory with no error. + List keyTypes = new List { KeyAlgorithm.RSA_1024, KeyAlgorithm.RSA_2048, KeyAlgorithm.RSA_3072, KeyAlgorithm.RSA_4096, KeyAlgorithm.EC_prime256v1, KeyAlgorithm.EC_secp384r1, KeyAlgorithm.EC_secp521r1 }; req.Includes = new Filters() { KeyTypes = keyTypes }; + //ACM omits ACME-origin certificates from ListCertificates unless the origins are requested + //explicitly, so every origin the SDK defines is listed. CertificateKeyPairOrigin is a + //ConstantClass rather than an enum and cannot be enumerated at runtime, so if AWS adds a new + //origin it must be added here or those certificates will go missing from inventory. + List keyPairOrigins = new List { CertificateKeyPairOrigin.AWS_MANAGED, CertificateKeyPairOrigin.CUSTOMER_PROVIDED, CertificateKeyPairOrigin.ACME }; + req.CertificateKeyPairOrigins = keyPairOrigins; + //Only fetch certificates that have been issued at one point req.CertificateStatuses = new List { CertificateStatus.ISSUED, CertificateStatus.INACTIVE, CertificateStatus.EXPIRED, CertificateStatus.REVOKED }; - req.MaxItems = 100; + //1000 is the documented maximum for ListCertificates. A larger page means fewer + //round trips; the per-certificate GetCertificate/ListTagsForCertificate calls below + //dominate either way. + req.MaxItems = 1000; + Logger.LogDebug($"Requesting ACM certificates with key pair origins [{string.Join(", ", keyPairOrigins)}] and key types [{string.Join(", ", keyTypes)}]."); Logger.LogTrace($"ListCertificatesRequest JSON: {JsonConvert.SerializeObject(req)}"); ListCertificatesResponse AllCertificates; do { AllCertificates = AsyncHelpers.RunSync(() => AcmClient.ListCertificatesAsync(req));//Fetch batch of certificates from ACM API - Logger.LogTrace($"AllCertificates JSON: {JsonConvert.SerializeObject(AllCertificates)}"); + //Interpolated arguments are built before LogTrace is called, so an unguarded + //SerializeObject runs even when trace logging is off. These dumps are large (a full + //page of certificate summaries), so the check pays for itself. + if (Logger.IsEnabled(LogLevel.Trace)) + Logger.LogTrace($"AllCertificates JSON: {JsonConvert.SerializeObject(AllCertificates)}"); totalCertificates += AllCertificates.CertificateSummaryList.Count; Logger.LogDebug($"Found {AllCertificates.CertificateSummaryList.Count} Certificates In Batch Amazon Certificate Manager Job."); + //Key pair origin is already present on the summary, so logging the breakdown costs no + //extra API calls and makes a future "certificates are missing" report diagnosable. + Logger.LogDebug($"Batch key pair origins: {string.Join(", ", AllCertificates.CertificateSummaryList.GroupBy(c => c.CertificateKeyPairOrigin?.Value ?? "unspecified").Select(g => $"{g.Key}={g.Count()}"))}"); + + //Certificate type decides PrivateKeyEntry below, so log the same breakdown to make a + //"Command will not let me renew this" report diagnosable from the log alone. + Logger.LogDebug($"Batch certificate types: {string.Join(", ", AllCertificates.CertificateSummaryList.GroupBy(c => c.Type?.Value ?? "unspecified").Select(g => $"{g.Key}={g.Count()}"))}"); + inventoryItems.AddRange(AllCertificates.CertificateSummaryList.Select( c => { try { - return BuildInventoryItem(c.CertificateArn); + return BuildInventoryItem(c.CertificateArn, c.Type); } catch { @@ -150,14 +171,24 @@ internal JobResult PerformInventory(AwsExtensionCredential awsCredentials, Inven warningFlag = true; return new CurrentInventoryItem(); } - }).Where(acsii => acsii?.Certificates != null).ToList()); + }).Where(acsii => acsii?.Certificates != null)); req.NextToken = AllCertificates.NextToken; } while (AllCertificates.NextToken != null); + if (totalCertificates == 0) + { + //An empty result is indistinguishable from an over-restrictive filter, so record the + //exact filters that were applied rather than leaving the next investigation guessing. + Logger.LogDebug($"ACM returned no certificates for {storeRef} in region {awsCredentials.Region.SystemName}. " + + $"Filters applied - key pair origins [{string.Join(", ", keyPairOrigins)}], key types [{string.Join(", ", keyTypes)}], " + + $"statuses [{string.Join(", ", req.CertificateStatuses)}]."); + } + int skipped = totalCertificates - inventoryItems.Count; string region = awsCredentials.Region.SystemName; - Logger.LogTrace($"inventoryItems Response JSON: {JsonConvert.SerializeObject(inventoryItems)}"); + if (Logger.IsEnabled(LogLevel.Trace)) + Logger.LogTrace($"inventoryItems Response JSON: {JsonConvert.SerializeObject(inventoryItems)}"); siu.Invoke(inventoryItems); @@ -211,7 +242,7 @@ internal static string StoreRef(string clientMachine, string storePath) return $"store [ClientMachine='{clientMachine}', StorePath='{storePath}']"; } - protected virtual CurrentInventoryItem BuildInventoryItem(string alias) + protected virtual CurrentInventoryItem BuildInventoryItem(string alias, CertificateType certificateType) { try { @@ -224,12 +255,19 @@ protected virtual CurrentInventoryItem BuildInventoryItem(string alias) { { "ACM Tags", GetCertificateTagsFromArn(alias) } }; + // Only user-imported certificates have a private key ACM accepts a replacement for. ACM holds no + // private key for the certificates it issues itself - Amazon-issued, Private CA-issued, and + // ACME-issued - so reporting those as private key entries makes Command offer a renewal that + // Management is guaranteed to reject. Type is returned by ListCertificates, so this costs no + // extra API call. An absent Type is treated as not-imported: suppressing a renewal that would + // have worked is recoverable, offering one that always fails is not. + bool isUserImported = certificateType == CertificateType.IMPORTED; CurrentInventoryItem acsi = new CurrentInventoryItem() { Alias = alias, Certificates = new[] { base64Cert }, ItemStatus = OrchestratorInventoryItemStatus.Unknown, - PrivateKeyEntry = true, + PrivateKeyEntry = isUserImported, UseChainLevel = false, Parameters = entryParams }; @@ -250,9 +288,12 @@ private string GetCertificateFromArn(string arn) Logger.MethodEntry(); Logger.LogTrace($"arn: {arn}"); GetCertificateRequest GetCertificateRequest = new GetCertificateRequest(arn); - Logger.LogTrace($"GetCertificateRequest Serialized Auth Request: {JsonConvert.SerializeObject(GetCertificateRequest)}"); + //Runs once per certificate, so guard the serialization - see the note in PerformInventory. + if (Logger.IsEnabled(LogLevel.Trace)) + Logger.LogTrace($"GetCertificateRequest Serialized Auth Request: {JsonConvert.SerializeObject(GetCertificateRequest)}"); GetCertificateResponse GetCertificateResponse = AsyncHelpers.RunSync(() => AcmClient.GetCertificateAsync(GetCertificateRequest)); - Logger.LogTrace($"GetCertificateRequest Serialized Auth Response: {JsonConvert.SerializeObject(GetCertificateResponse)}"); + if (Logger.IsEnabled(LogLevel.Trace)) + Logger.LogTrace($"GetCertificateRequest Serialized Auth Response: {JsonConvert.SerializeObject(GetCertificateResponse)}"); return GetCertificateResponse.Certificate; } catch (Exception e) @@ -271,13 +312,8 @@ private string GetCertificateTagsFromArn(string arn) ListTagsForCertificateRequest getTagsRequest = new ListTagsForCertificateRequest() { CertificateArn = arn }; ListTagsForCertificateResponse getTagsResponse = AsyncHelpers.RunSync(() => AcmClient.ListTagsForCertificateAsync(getTagsRequest)); - string tags = ""; - foreach (Amazon.CertificateManager.Model.Tag tag in getTagsResponse.Tags) - { - tags += $",{tag.Key}={tag.Value}"; - } - - return tags.Length > 0 ? tags.Substring(1) : tags; + //Management.ParseACMTags reads this back, so the format must stay "Name1=Value1,Name2=Value2". + return string.Join(",", getTagsResponse.Tags.Select(t => $"{t.Key}={t.Value}")); } catch (Exception e) { @@ -296,9 +332,16 @@ private string RemoveAnchors(string base64Cert) try { Logger.MethodEntry(); + // Match the delimiters themselves and trim the surrounding whitespace separately. An + // earlier version folded the newlines into the match ("\n-----END CERTIFICATE-----\n"), + // so a PEM whose final newline was absent kept the footer text inside the Base64 body. + // ACM always sends that newline, but the dependency was invisible and unnecessary. + // Interior line wrapping is left intact - Command tolerates it, and preserving it keeps + // the emitted value byte-identical to what previous releases produced. var noAnchors = base64Cert.Replace("\r", "") - .Replace("-----BEGIN CERTIFICATE-----\n", "") - .Replace("\n-----END CERTIFICATE-----\n", ""); + .Replace("-----BEGIN CERTIFICATE-----", "") + .Replace("-----END CERTIFICATE-----", "") + .Trim(); Logger.LogTrace($"No Anchors: {noAnchors}"); return noAnchors; } diff --git a/aws-acm-orchestrator/Jobs/Management.cs b/aws-acm-orchestrator/Jobs/Management.cs index 4053771..a101be6 100644 --- a/aws-acm-orchestrator/Jobs/Management.cs +++ b/aws-acm-orchestrator/Jobs/Management.cs @@ -45,7 +45,7 @@ public class Management : IManagementJobExtension private static String certEnd = "\n-----END CERTIFICATE-----"; // ReSharper disable once FieldCanBeMadeReadOnly.Local - private static Func _pemify = (ss => ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + _pemify(ss.Substring(64))); + internal static Func _pemify = (ss => ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + _pemify(ss.Substring(64))); internal IAmazonCertificateManager AcmClient; internal ILogger Logger; @@ -157,20 +157,31 @@ internal JobResult PerformAddition(AwsExtensionCredential awsCredentials, Manage if (DescribeCertificateResponse.Certificate.Type != CertificateType.IMPORTED) { - Logger.LogError($"Non User Imported Certificate Type Found for {storeRef}, alias {config.JobCertificate.Alias}"); + // Report the actual Type and key pair origin. ACME-issued certificates are not + // user-imported and so land here too, and a message naming only "Amazon-issued or + // Private CA-issued" does not tell an operator using ACME why their renewal failed. + string certType = DescribeCertificateResponse.Certificate.Type?.Value ?? "unspecified"; + string keyPairOrigin = DescribeCertificateResponse.Certificate.CertificateKeyPairOrigin?.Value ?? "unspecified"; + Logger.LogError($"Non User Imported Certificate Type Found for {storeRef}, alias {config.JobCertificate.Alias} " + + $"(Type={certType}, CertificateKeyPairOrigin={keyPairOrigin})"); return new JobResult { Result = OrchestratorJobStatusJobResult.Failure, JobHistoryId = config.JobHistoryId, FailureMessage = $"AWS Certificate Manager only supports overwriting user-imported certificates. The certificate " + - $"'{config.JobCertificate.Alias}' in {storeRef} is Amazon-issued or Private CA-issued and cannot be replaced." + $"'{config.JobCertificate.Alias}' in {storeRef} has Type '{certType}' and key pair origin " + + $"'{keyPairOrigin}' (not '{CertificateType.IMPORTED}'), so ACM cannot replace it. Certificates that " + + $"ACM issues itself - including Amazon-issued, Private CA-issued, and ACME-issued certificates - " + + $"are inventoried for visibility but must be renewed through ACM rather than Keyfactor Command." }; } } // Load PFX - Logger.LogTrace($"Loading certificate content: {config.JobCertificate.Contents}"); + // Never log JobCertificate.Contents - it is the base64 PFX, i.e. the private key + // material. Trace is exactly the level support asks operators to turn on. + Logger.LogTrace($"Loading certificate content ({config.JobCertificate.Contents?.Length ?? 0} base64 characters)."); byte[] pfxBytes = Convert.FromBase64String(config.JobCertificate.Contents); Pkcs12Store p; using (var pfxBytesMemoryStream = new MemoryStream(pfxBytes)) @@ -371,7 +382,7 @@ internal JobResult PerformRemoval(AwsExtensionCredential awsCredentials, Managem } } - private static MemoryStream GetChain(Pkcs12Store store, string alias) + internal static MemoryStream GetChain(Pkcs12Store store, string alias) { string ccs = ""; @@ -418,10 +429,10 @@ internal static bool IsAcmCertificateArn(string alias) string trimmed = alias.Trim(); return trimmed.StartsWith("arn:aws:acm:", StringComparison.OrdinalIgnoreCase) - && trimmed.Contains(":certificate/"); + && trimmed.Contains(":certificate/", StringComparison.OrdinalIgnoreCase); } - private List ParseACMTags(Dictionary jobProperties) + internal List ParseACMTags(Dictionary jobProperties) { List acmTags = new List(); diff --git a/aws-acm-orchestrator/aws-acm-orchestrator.csproj b/aws-acm-orchestrator/aws-acm-orchestrator.csproj index 2a79c9b..7007c25 100644 --- a/aws-acm-orchestrator/aws-acm-orchestrator.csproj +++ b/aws-acm-orchestrator/aws-acm-orchestrator.csproj @@ -1,33 +1,33 @@  - - true - net8.0;net10.0 - true - disable - Keyfactor.Extensions.Orchestrator.Aws.Acm - Keyfactor.Extensions.Orchestrator.Aws.Acm - + + true + net8.0;net10.0 + true + disable + Keyfactor.Extensions.Orchestrator.Aws.Acm + Keyfactor.Extensions.Orchestrator.Aws.Acm + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - Always - - + + Always + + - \ No newline at end of file + diff --git a/docsource/aws-acm-v3.md b/docsource/aws-acm-v3.md index 6c5e2c6..3538ae6 100644 --- a/docsource/aws-acm-v3.md +++ b/docsource/aws-acm-v3.md @@ -1,40 +1,77 @@ ## Overview -The AWS Certificate Manager v3 Store Type provides enhanced authentication options for managing certificates in ACM. -Each defined Certificate Store of this type targets a specific AWS Region with a specific Destination account in mind. -Therefore, each Certificate Store instance is intended to represent a single Role's certificates in a single AWS Region. +The `AWS-ACM-v3` store type represents **one AWS region inside one AWS account**. Certificate +aliases within the store are full ACM certificate ARNs, and the region plus the identity to operate +as are both carried by the store's `Client Machine` and `Store Path` fields. -Some authentication configurations do not adhere strictly to this, so when using the various methods offered in the Default SDK auth option, -a full understanding of how permissions work in AWS is recommended. -In most scenarios using the Default SDK option, the Assume Role flag should also be set to avoid confusion, and use the Role ARN in the `Client Machine` field as the Destination account. +Define a separate certificate store for every region you hold certificates in; a single store cannot +span regions. -## Requirements +#### Store Path and Client Machine + +`Store Path` accepts two formats, and which one you use decides where the Role ARN comes from: + +| Format | Example `Store Path` | Role ARN comes from | `Client Machine` | +|---|---|---|---| +| Region only (legacy) | `us-east-1` | the `Client Machine` field | **Required** — full Role ARN | +| `\|` | `arn:aws:iam::123456789012:role/Keyfactor-ACM-Role\|us-east-1` | the `Store Path` itself | Informational only | + +Stores created by cross-account Discovery always use the second form. Both are supported +indefinitely — existing region-only stores do not need to be rewritten. + +`Client Machine` may also carry a **credential profile prefix** when using Default SDK auth, e.g. +`[myprofile]arn:aws:iam::123456789012:role/Keyfactor-ACM-Role`. The named profile must exist in the +shared AWS credentials file on the orchestrator host. -Configuring authentication with AWS requires understanding how the authentication flow works. -Depending on the intended authentication method, the required configuration in AWS may require one or more Roles with the correct permissions. +#### Which identity actually reaches ACM -The intended Destination account, usually the Role ARN specified in the `Client Machine` field, which is the final identity used to perform the actual work in AWS ACM, needs the following permissions: +Every authentication method resolves to a single **destination identity** — the assumed Role ARN +when the store performs an Assume Role, or the originating identity (ambient SDK credentials or the +named profile) when it does not. **The destination identity is the one that needs the ACM +permissions.** -~~~ -Inventory required actions: +For every Assume-Role method the **originating** identity additionally needs `sts:AssumeRole` on the +target role, and that role's trust policy must allow the originating principal. - "acm:ListCertificates", - "acm:GetCertificate", - "acm:ListTagsForCertificate" +> [!TIP] +> When using Default SDK auth, also set `DefaultSdkAssumeRole` and put the destination Role ARN in +> `Client Machine`. Without it, the identity reaching ACM is whatever the SDK happened to resolve on +> the orchestrator host, which is easy to misconfigure and hard to diagnose. - Management required actions: +## Requirements + +Grant the following ACM actions to the **destination identity**. Copy/adapt JSON policy documents — +including the separate policies needed for cross-account Discovery — are in +[Example IAM policies for the destination identity](#example-iam-policies-for-the-destination-identity), +and matching trust policies are in [Example trust policies](#example-trust-policies). + +| Job type | Required actions | Notes | +|---|---|---| +| **Inventory** | `acm:ListCertificates`
`acm:GetCertificate`
`acm:ListTagsForCertificate` | `ListCertificates` cannot be scoped to a resource and must be granted on `*` | +| **Management** (add / renew / remove) | `acm:DescribeCertificate`
`acm:ImportCertificate`
`acm:DeleteCertificate` | Renewal is an `ImportCertificate` call supplying the existing ARN, so no extra action is needed | +| **Management** with the `ACM Tags` entry parameter | *(none beyond `acm:ImportCertificate`)* | Tags on import are governed by `acm:ImportCertificate`, which carries the `aws:RequestTag`/`aws:TagKeys` condition keys. Add `acm:AddTagsToCertificate` only if your policy standards require it to be explicit | +| **Discovery** (management identity) | `organizations:ListAccounts`
`sts:AssumeRole` | If `ListAccounts` is denied, Discovery logs a warning and falls back to single-account discovery | +| **Discovery** (per-account role) | `acm:ListCertificates`
`ec2:DescribeRegions` | `DescribeRegions` is only used when the region list is blank or `*` | - "acm:DeleteCertificate", - "acm:DescribeCertificate", - "acm:ImportCertificate" -~~~ +A store that only needs Inventory does not need the Management actions; grant the narrowest set the +store's jobs actually use. ## Global Store Type Section -The latest version of the Store Type supporting ACM (AWS Certificate Manager) is `AWS-ACM-v3`. -Previous store types are no longer supported and should be migrated to the new Store Type definition. -When migrating to the `AWS-ACM-v3` type please note that field usage has changed and does not map over directly. +#### Store type version and required field values + +The latest Store Type supporting ACM (AWS Certificate Manager) is `AWS-ACM-v3`. The earlier +`AWS-ACM`, `AwsCerManO` and `AwsCerManA` types are no longer supported and must be migrated — +field usage changed and does **not** map across directly, so stores have to be recreated rather than +converted. See +[Migrating from an older ACM store type](#migrating-from-an-older-acm-store-type). > [!WARNING] -> When creating Certificate Stores, all available Secret type fields need to have a value set for them, even if that is "No Value". -> Failing to set these Secret fields, even when not in use, causes errors that may require database access to fix. +> When creating Certificate Stores, **all available Secret type fields need to have a value set for +> them, even if that is "No Value"**. The secret fields on this store type are `OAuthClientId`, +> `OAuthClientSecret`, `IAMUserAccessKey` and `IAMUserAccessSecret`. Failing to set these Secret +> fields, even when not in use, causes errors that may require database access to fix. + +> [!NOTE] +> `Supports Reenrollment` is unchecked and cannot be enabled: ACM provides no CSR-generation API for +> imported certificates, so there is no on-device key generation path to implement. diff --git a/docsource/content.md b/docsource/content.md index 10dca4c..70b2096 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -1,104 +1,608 @@ ## Overview -> [!WARNING] -> -> **The `AWS-ACM-v3` store type identifies each certificate store by a single AWS `Region` in the `Store Path` field, with the full Role ARN in `Client Machine`.** +The AWS Certificate Manager (ACM) Universal Orchestrator extension manages SSL/TLS certificates +held in [AWS Certificate Manager](https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html) +from Keyfactor Command. It supports **Inventory**, **Management** (add / renew / remove) and +**cross-account Discovery**. + +Each certificate store instance maps to **exactly one AWS region in exactly one AWS account**. +Certificate aliases within a store are full ACM certificate ARNs, for example +`arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012`. + +Authentication is handled by the shared [aws-auth-library](https://github.com/Keyfactor/aws-auth-library), +so the extension supports the AWS Default SDK credential chain (including EC2 instance roles and +named credential profiles), IAM User access keys, and OAuth 2.0 / OIDC federation against a third-party +identity provider. The Default SDK and IAM User methods can optionally finish by performing an +**Assume Role** into the account that actually holds the certificates; the OAuth method always +assumes a role, via `sts:AssumeRoleWithWebIdentity`. + +> [!IMPORTANT] +> **`Store Path` accepts two formats.** > -> **New with cross-account Discovery:** `Store Path` now also accepts a **self-contained** value of the form **`|`** — for example `arn:aws:iam::123456789012:role/KeyfactorACMDiscoveryRole|us-east-1` — which carries **both** the Role ARN to assume **and** the Region in a single field. Certificate stores created by cross-account Discovery use this combined format, and for those stores the `Client Machine` field is informational only. Legacy region-only `Store Path` values (with the Role ARN in `Client Machine`) continue to work. +> | Format | Example | Where the Role ARN comes from | +> |---|---|---| +> | Region only (legacy) | `us-east-1` | the `Client Machine` field | +> | `\|` (self-contained) | `arn:aws:iam::123456789012:role/KeyfactorACMDiscoveryRole\|us-east-1` | the `Store Path` itself; `Client Machine` is informational only | > -> **Migrating from `AWS-ACM`, `AwsCerManO`, or `AwsCerManA`:** those older store types are **not** compatible and must be recreated as `AWS-ACM-v3`. - -AWS Certificate Manager is a service that lets you easily provision, manage, and deploy public and private Secure Sockets Layer/Transport Layer Security (SSL/TLS) -certificates for use with AWS services and your internal connected resources. -SSL/TLS certificates are used to secure network communications and establish the identity of websites over the Internet as well as resources on private networks. -AWS Certificate Manager removes the time-consuming manual process of purchasing, uploading, and renewing SSL/TLS certificates. -The orchestrator supports OAuth OIDC authentication, as well as AWS IAM accounts, and various options provided by the AWS SDK such as EC2 instance credentials. -The OAuth OIDC support allows authentication against a 3rd party identity provider in AWS. -After initial authentication, temporary credentials are used by using the Assume Role functionality in AWS. - -This integration also supports the reading of existing certificate ACM key/value pair tags during inventory and adding these tags when adding new certificates. -Modifying and adding ACM tags during certificate renewal, however, is NOT supported. -This is because the AWS API does not allow for ACM tag modification when updating a certificate in one step. -This would need to be done in multiple steps, leading to the possibility of the certificate being left in an error state if any intermediate step were to fail. -However, while the modification/addition of ACM tags is not supported, all existing ACM tags WILL remain in place during renewal. - +> Stores created by cross-account Discovery always use the self-contained form. Both formats are +> supported indefinitely — existing region-only stores do not need to be changed. + +> [!WARNING] +> **Migrating from `AWS-ACM`, `AwsCerManO`, or `AwsCerManA`:** those store types are **not** +> compatible with `AWS-ACM-v3` and must be recreated. See +> [Migrating from an older ACM store type](#migrating-from-an-older-acm-store-type). + +### What this extension does and does not do + +| Capability | Supported | Notes | +|---|---|---| +| Inventory certificates | ✅ | Includes certificates in `ISSUED`, `INACTIVE`, `EXPIRED` and `REVOKED` states | +| Add (import) a certificate | ✅ | PFX is split into certificate, private key and chain, and re-encoded as PEM for `ImportCertificate` | +| Renew / replace a certificate | ✅ | **Only for certificates whose ACM `Type` is `IMPORTED`** | +| Remove a certificate | ✅ | **Only for certificates whose ACM `Type` is `IMPORTED`** | +| Cross-account Discovery | ✅ | Enumerates accounts via AWS Organizations and probes each region | +| Read existing ACM tags | ✅ | Surfaced on the `ACM Tags` entry parameter during Inventory | +| Set ACM tags on a **new** import | ✅ | `Name1=Value1,Name2=Value2` format | +| Change ACM tags on **renewal** | ❌ | AWS does not accept tags when reimporting a certificate; see [ACM tags](#acm-tags) | +| Reenrollment / on-device key generation | ❌ | ACM does not expose a CSR-generation API for imported certificates | + +> [!NOTE] +> **Amazon-issued, Private CA and ACME certificates are inventory-only.** Their ACM `Type` is not +> `IMPORTED`, so ACM rejects any attempt to replace or delete them through this API path. The +> Inventory job marks them `PrivateKeyEntry = false` so Command does not offer a renewal that is +> guaranteed to fail. See [Certificate types and renewal](#certificate-types-and-renewal). + ### Documentation -- [How AWS works in this extension (aws-auth-library)](https://github.com/Keyfactor/aws-auth-library) -- [AWS Region Codes](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html) +- [How AWS authentication works in this extension (aws-auth-library)](https://github.com/Keyfactor/aws-auth-library) +- [AWS Region codes](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html) +- [AWS Certificate Manager user guide](https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html) +- [ACM service quotas and API request rates](https://docs.aws.amazon.com/acm/latest/userguide/acm-limits.html) ## Requirements -### Migrate existing ACM stores to the new type (AWS Certificate Manager v3) +### Migrating from an older ACM store type + +Field usage changed in v3 and does **not** map across directly: + +| Older store types (`AWS-ACM`, `AwsCerManO`, `AwsCerManA`) | `AWS-ACM-v3` | +|---|---| +| `ServerUsername` / `ServerPassword` carried credentials | No longer used. Dedicated IAM and OAuth fields replace them | +| AWS Region supplied as an Entry Parameter | Region is part of `Store Path`; the Entry Parameter is gone | +| `Client Machine` held a host or short name | `Client Machine` holds the **full Role ARN** used for Assume Role calls | +| One store could span regions | One store = **one region**; define a separate store per region | + +Migration steps: + +1. Create the `AWS-ACM-v3` store type (once per Command instance). +2. Recreate each certificate store against the new type. +3. Run an Inventory job on each new store to begin tracking those certificates again. +4. Delete the deprecated stores and store types once nothing depends on them. + +There is no automated migration utility. + +### Key concept — the destination identity + +> **Whichever identity the extension ends up operating as — the assumed Role ARN, or the +> *originating* identity when no Assume Role is performed — is the *destination identity*, and it +> is what must hold the ACM permissions** (see +> [Example IAM policies](#example-iam-policies-for-the-destination-identity)). +> +> For every Assume-Role method the **originating** identity additionally needs `sts:AssumeRole` on +> the target role, and the target role's **trust policy** must allow that originating principal +> (see [Example trust policies](#example-trust-policies)). + +Getting this distinction wrong is the most common cause of `AccessDenied` from a store that looks +correctly configured. Work out which identity is the destination *first*, then attach the ACM +permissions there. + +### Common requirements (all methods) + +- **Region** — supplied through `Store Path`, either as the whole value (`us-east-1`) or as the + part after the pipe (`|us-east-1`). Must be the region the certificates live in. +- **Client Machine** — the full Role ARN to assume, e.g. + `arn:aws:iam::123456789012:role/Keyfactor-ACM-Role`. Prefix with `[profile-name]` to select a + named credential profile under Default SDK auth. Informational only for stores that use the + self-contained `|` store path. +- **ACM permissions on the destination identity** — see + [Example IAM policies](#example-iam-policies-for-the-destination-identity). +- **One store per region per account** — additional regions need their own stores. + +> [!WARNING] +> When creating certificate stores, **every `Secret`-type field must be given a value**, even if +> that value is "No Value". The secret fields on this store type are `OAuthClientId`, +> `OAuthClientSecret`, `IAMUserAccessKey` and `IAMUserAccessSecret`. Leaving an unused secret field +> empty causes errors that may require database access to correct. + +### Method configuration + +Choose **one** method per certificate store and set the auth toggles you are **not** using to +`false`. + +#### Default SDK (no assume) + +- `UseDefaultSdkAuth` = `true`, `DefaultSdkAssumeRole` = `false` +- `Client Machine` is ignored for credential resolution -Field usage has changed in v3, notably: -* `ServerUsername` and `ServerPassword` are no longer used - * Specific fields for IAM and OAuth are defined for credentials of those types -* `Store Path` only allows a __single__ AWS Region to be defined - * The Entry Parameter for AWS Region is no longer used -* `Client Machine` requires the _full_ Role ARN to be used for Assume Role calls +The orchestrator host's **ambient AWS identity** is the destination and must hold the ACM +permissions directly. The AWS SDK resolves it in the usual order: `AWS_*` environment variables, +the `[default]` profile in the shared credentials file, then the EC2 instance role. -As a result, previous Store Types are no longer supported, and Certificate Stores of those types need to be migrated to the v3 type. -Inventory jobs will need to be to run after creating the new Certificate Stores to begin tracking those certificates again. -The deprecated Stores and Store Types can be deleted after they are no longer needed. +#### Default SDK + Assume Role -_Currently, there is no provided migration utility to perform this programmatically._ +- `UseDefaultSdkAuth` = `true`, `DefaultSdkAssumeRole` = `true` +- `Client Machine` = destination Role ARN + +The ambient identity is the **originating** account and performs `sts:AssumeRole` into the +`Client Machine` role. The ambient identity needs `sts:AssumeRole`; the destination role's trust +policy must allow it and the destination role holds the ACM permissions. + +> This is the recommended Default SDK configuration. Without the assume flag it is easy to lose +> track of which identity is actually reaching ACM. + +#### Credential Profile (± Assume Role) + +Prefix `Client Machine` with a profile name, e.g. +`[myprofile]arn:aws:iam::123456789012:role/Keyfactor-ACM-Role`, and set `UseDefaultSdkAuth` = `true`. +The named profile must exist in the shared AWS credentials file **on the orchestrator host**. + +- **No assume** (`DefaultSdkAssumeRole` = `false`): the profile identity is the destination and needs + the ACM permissions directly. +- **With assume** (`DefaultSdkAssumeRole` = `true`): the profile is the originating identity and + assumes the Role ARN that follows the prefix. + +#### IAM User (Access Key + Secret) + +- `UseIAM` = `true` +- `IAMUserAccessKey` (e.g. `AKIAIOSFODNN7EXAMPLE`) +- `IAMUserAccessSecret` (e.g. `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) +- `Client Machine` = destination Role ARN to assume + +**AWS setup:** the IAM user needs `sts:AssumeRole` on the destination role; that role's trust policy +must allow the IAM user (see [Assume-Role trust](#assume-role--iam-user-default-sdk--assume-or-credential-profile--assume)); +the role holds the [ACM permissions](#example-iam-policies-for-the-destination-identity). + +> AWS does not support programmatic access for AWS SSO accounts. This must be a standard IAM User +> with an Access Key credential type. + +#### IAM User + ExternalId + +As **IAM User**, plus set `ExternalId`. The destination role's trust policy must include a matching +`sts:ExternalId` condition (see [Assume Role with ExternalId](#assume-role-with-externalid)). Use +this when the role owner requires a shared secret to prevent the +[confused-deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). + +#### OAuth 2.0 (OIDC federation) + +- `UseOAuth` = `true` +- `OAuthClientId`, `OAuthClientSecret` +- `OAuthUrl` — the identity provider's **token endpoint**, e.g. + `https://your-tenant.okta.com/oauth2/default/v1/token` +- `OAuthScope`, `OAuthGrantType` (`client_credentials`) +- `Client Machine` = destination Role ARN (a federated web-identity role) + +The extension requests an OAuth token and calls `sts:AssumeRoleWithWebIdentity` on the destination +role. + +**AWS setup:** create an IAM **OIDC identity provider** for the token issuer, and set the destination +role's trust policy to allow `sts:AssumeRoleWithWebIdentity` for that provider with the appropriate +`aud` (and optionally `sub`) conditions — see [OAuth web-identity trust](#oauth--web-identity-trust). + +> [!NOTE] +> OAuth provider constraints: the client id/secret must be accepted as an HTTP **Basic** auth header +> (not a POST body); **DPoP must be disabled** on the app; and the token lifetime must be +> **≤ 12 hours**, because STS caps `AssumeRoleWithWebIdentity` `DurationSeconds` at 43200 and the +> session duration is derived from the token's `expires_in`. ### Setting up AWS Authentication (Examples) > [!NOTE] -> Several different options are offered for authenticating with AWS. -> Documentation for how these options work is now located in the [aws-auth-library](https://github.com/Keyfactor/aws-auth-library) repository. +> Several different options are offered for authenticating with AWS. Documentation for how these +> options work is maintained in the [aws-auth-library](https://github.com/Keyfactor/aws-auth-library) +> repository. -The following examples show potential configurations for Roles in AWS with different selected authentication methods. -Your configuration steps may differ depending on specific requirements of your use case. +The following examples show potential configurations for Roles in AWS with different selected +authentication methods. Your configuration steps may differ depending on the specific requirements +of your use case. In every case the **destination identity** must hold the ACM permissions, and +every Assume-Role method additionally requires the originating identity to have `sts:AssumeRole` and +the target role's trust policy to allow that principal.
EC2 instance credentials using Default SDK and Assume Role Select the `Use Default SDK Auth` option to allow the integration to load EC2 instance credentials. -If the EC2 Role assigned to the instance is intended as the Destination account identity to use with ACM, no additional Role needs to be configured. -If the EC2 Role assigned to the instance is only to be used initially, and a new Role ARN is designated as the Destination account in the `Client Machine` field, -then the `Assume new Role using Default SDK Auth` should also be selected. +If the EC2 Role assigned to the instance is itself the destination identity to use with ACM, no +additional Role needs to be configured. If it is only to be used initially, and a separate Role ARN +is designated as the destination in the `Client Machine` field, also select +`Assume new Role using Default SDK Auth`. -### AWS Setup -_Note: In this scenario, the AWS-ACM-v3 extension needs to be running inside an EC2 instance._ -1. Assign or note the existing IAM Role assigned to the EC2 instance running. [Found in EC2 here](docsource/images/ec2-instance-iam-role.gif). -2. Ensure a [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role. [Example](docsource/images/ec2-role-arn-trust-relationship.gif). -3. Verify the permissions match the requirements for accessing ACM. +**AWS Setup** -
+_Note: in this scenario the extension must be running inside an EC2 instance._ +1. Assign or note the existing IAM Role assigned to the EC2 instance. + [Found in EC2 here](docsource/images/ec2-instance-iam-role.gif). +2. If Assume Role is used, ensure a + [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) + on the destination role allows the instance role to assume it. + [Example](docsource/images/ec2-role-arn-trust-relationship.gif). +3. Verify the destination role's permissions match + [the ACM requirements](#example-iam-policies-for-the-destination-identity). + +
OAuth OIDC Identity Provider (Okta example) Select the `Use OAuth` option for a certificate store to use an OAuth Identity Provider. -### AWS Setup -1. A 3rd party [Identity Provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) similar to [this](docsource/images/AWSIdentityProvider.gif) needs to be set up in AWS. -2. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) needs to be created to be used with your Identity Provider. -3. Ensure the [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role with the Identity Provider. [Example](docsource/images/AWSRole2.gif). -4. Verify the permissions match the requirements for accessing ACM. +**AWS Setup** -### OKTA Setup -1. Ensure your Authorization Server Is Set up in OKTA. Here is a [sample](docsource/images/OktaSampleAuthorizationServer.gif). -2. Ensure the appropriate scopes are set up in Okta. Here is a [sample](docsource/images/OktaSampleAuthorizationServer-scopes.gif). -3. Set up an Okta App with similar settings to [this](docsource/images/OktaApp1.gif) and [this](docsource/images/OktaApp2.gif). +1. A third-party + [Identity Provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) + similar to [this](docsource/images/AWSIdentityProvider.gif) needs to be set up in AWS. +2. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) + needs to be created for use with your Identity Provider. + [Example](docsource/images/AWSRole1.gif). +3. Ensure the + [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) + is set up for that role with the Identity Provider, allowing `sts:AssumeRoleWithWebIdentity` with + the appropriate `aud` (and optionally `sub`) conditions. + [Example](docsource/images/AWSRole2.gif), and see + [OAuth web-identity trust](#oauth--web-identity-trust). +4. Verify the permissions match + [the ACM requirements](#example-iam-policies-for-the-destination-identity). -
+**Okta Setup** + +1. Ensure your Authorization Server is set up in Okta. Here is a + [sample](docsource/images/OktaSampleAuthorizationServer.gif). +2. Ensure the appropriate scopes are set up in Okta. Here is a + [sample](docsource/images/OktaSampleAuthorizationServer-scopes.gif). +3. Set up an Okta App with similar settings to [this](docsource/images/OktaApp1.gif) and + [this](docsource/images/OktaApp2.gif). The app must send the client id/secret as a **Basic auth + header**, have **DPoP disabled**, and issue tokens with a lifetime **≤ 12 hours**. +
IAM User credentials to Assume Role Select the `Use IAM` option for a certificate store to use an IAM User credential. -### AWS Setup -1. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) to Assume with your IAM User needs to be created. -2. Ensure a [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role. [Example](docsource/images/AssumeRoleTrust.gif). -3. AWS does not support programmatic access for AWS SSO accounts. The account used here must be a [standard AWS IAM User](docsource/images/UserAccount.gif) with an Access Key credential type. -4. Verify the permissions match the requirements for accessing ACM. +**AWS Setup** + +1. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) to + assume with your IAM User needs to be created, and set as `Client Machine`. +2. Ensure a + [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) + is set up for that role that allows the IAM User. + [Example](docsource/images/AssumeRoleTrust.gif). If using the ExternalId modifier, add a matching + `sts:ExternalId` condition. +3. AWS does not support programmatic access for AWS SSO accounts. The account used here must be a + [standard AWS IAM User](docsource/images/UserAccount.gif) with an Access Key credential type. +4. Verify the permissions match + [the ACM requirements](#example-iam-policies-for-the-destination-identity).
+ +--- + +### Example IAM policies for the destination identity + +These permissions belong on the **destination identity** (the assumed Role ARN, or the +ambient/profile identity when no Assume Role is performed). The following examples are intended as +**copy/adapt templates** — scope `Resource` down to specific certificate ARNs where your policy +standards require it. + +#### Example 1: Inventory only (read-only) + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AcmInventory", + "Effect": "Allow", + "Action": [ + "acm:ListCertificates", + "acm:GetCertificate", + "acm:ListTagsForCertificate" + ], + "Resource": "*" + } + ] +} +``` + +`acm:ListCertificates` does not operate on a single resource and must be granted on `*`. The other +two actions can be scoped to `arn:aws:acm:::certificate/*`. + +#### Example 2: Inventory + Management (add, renew, remove) + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AcmList", + "Effect": "Allow", + "Action": "acm:ListCertificates", + "Resource": "*" + }, + { + "Sid": "AcmInventoryAndManage", + "Effect": "Allow", + "Action": [ + "acm:GetCertificate", + "acm:ListTagsForCertificate", + "acm:DescribeCertificate", + "acm:ImportCertificate", + "acm:DeleteCertificate" + ], + "Resource": "arn:aws:acm:::certificate/*" + }, + { + "Sid": "AcmTagOnImport", + "Effect": "Allow", + "Action": "acm:AddTagsToCertificate", + "Resource": "arn:aws:acm:::certificate/*" + } + ] +} +``` + +> [!NOTE] +> `acm:ImportCertificate` covers both new imports and renewals — a renewal is an `ImportCertificate` +> call that supplies the existing `CertificateArn`. +> +> Tags supplied on an import are governed by `acm:ImportCertificate` itself: AWS documents the +> `aws:RequestTag/${TagKey}` and `aws:TagKeys` condition keys on that action and lists no dependent +> action for it. The `AcmTagOnImport` statement above is therefore belt-and-braces — include it if +> your policy standards prefer the tagging action to be explicit, or if the same role also needs to +> tag certificates outside of an import. Omit it otherwise. + +#### Example 3: Cross-account Discovery + +Discovery uses two different identities. The **management identity** enumerates the organization: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "EnumerateOrgAccounts", + "Effect": "Allow", + "Action": "organizations:ListAccounts", + "Resource": "*" + }, + { + "Sid": "AssumeDiscoveryRoleInMembers", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::*:role/KeyfactorACMDiscoveryRole" + } + ] +} +``` + +The **per-account discovery role** (`KeyfactorACMDiscoveryRole` by default) needs only enough to +answer "does this region hold any certificates?": + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ProbeRegionsForCertificates", + "Effect": "Allow", + "Action": [ + "acm:ListCertificates", + "ec2:DescribeRegions" + ], + "Resource": "*" + } + ] +} +``` + +`ec2:DescribeRegions` is only used when the region list is left blank or set to `*`, to narrow the +scan to the regions each account actually has enabled. It can be omitted if you always supply an +explicit region list. See [Cross-account Discovery](#cross-account-discovery). + +--- + +### Example trust policies + +Every Assume-Role method requires a **trust policy** on the destination role that allows the +originating principal to assume it. (Default SDK *no-assume* and Credential Profile *no-assume* need +no trust policy — the identity is used directly.) + +#### Assume Role — IAM User, Default SDK + Assume, or Credential Profile + Assume + +`<>` is the IAM user ARN (IAM User method) or the ambient/profile identity +ARN (Default SDK / Credential Profile + Assume). For an EC2 instance role this is the role ARN, not +the instance profile ARN. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +#### Assume Role with ExternalId + +Add a matching `sts:ExternalId` condition; the store's `ExternalId` field must equal it. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole", + "Condition": { "StringEquals": { "sts:ExternalId": "<>" } } + } + ] +} +``` + +#### OAuth — web-identity trust + +The destination role for the OAuth method must trust the OIDC provider you registered for your token +issuer. Condition on the token `aud` (audience) and optionally `sub` — for `client_credentials`, +`sub` is typically the client id. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "Federated": "arn:aws:iam:::oidc-provider//" }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "/:aud": "<>", + "/:sub": "<>" + } + } + } + ] +} +``` + +#### Cross-account Discovery role trust + +Each member account's `KeyfactorACMDiscoveryRole` must trust the management identity that performs +the assume. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +--- + +### Cross-account Discovery + +A Discovery job walks an AWS Organization and reports back one certificate store per +account-and-region that actually holds certificates. Discovered stores use the self-contained +`|` store path. + +**How it resolves accounts and regions** + +1. Base credentials are resolved from the job's settings, then used to call + `organizations:ListAccounts`. +2. For each active account, the job assumes `arn:aws:iam:::role/`. +3. For each candidate region it calls `acm:ListCertificates`; a region with at least one certificate + becomes a discovered store. + +If `organizations:ListAccounts` is denied, the job logs a warning and **falls back to +single-account discovery** using the base credentials — it does not fail. + +**Discovery dialog field mapping** + +The standard Command Discovery dialog has no AWS-specific fields, so settings are carried on the +generic dialog fields: + +| Dialog field | Setting | Default / meaning | +|---|---|---| +| **Directories to search** | Management-account Role ARN to assume | Falls back to `Client Machine` if blank | +| **Directories to ignore** | Account IDs to skip | Comma-separated | +| **File name patterns to match** | `DiscoveryRoleName` — the IAM role name assumed in each member account | `KeyfactorACMDiscoveryRole` | +| **File extensions** | Regions to scan, comma-separated | Blank or `*` = every region in the base region's partition | + +> [!NOTE] +> Because the dialog carries no auth-method fields, an operator-launched Discovery job arrives with +> all auth toggles `false`. The job then **defaults to Default SDK auth**, and enables Assume Role +> automatically when "Directories to search" contains something that looks like an IAM role ARN +> (`arn:aws:iam:`, `arn:aws-us-gov:iam:` or `arn:aws-cn:iam:`). + +**Region scanning behaviour** + +- **Explicit list** (e.g. `us-east-1,us-east-2`) — used verbatim, no auto-detection. +- **Blank or `*`** — every region in the base region's partition, narrowed per account by + `ec2:DescribeRegions` to the regions that account has actually enabled. + +There is no hard-coded list of "common" regions. Scanning every region in a large organization is +one `ListCertificates` call per account per region, so prefer an explicit list when you know where +your certificates are. + +--- + +### Operational behaviour and limitations + +#### Certificate types and renewal + +ACM reports a `Type` for every certificate. Only `IMPORTED` certificates can be replaced or deleted +through this extension: + +| ACM `Type` | Origin | Inventory | Renew / Remove | +|---|---|---|---| +| `IMPORTED` | Uploaded by you or by Command | ✅ | ✅ | +| `AMAZON_ISSUED` | Issued by ACM's public CA | ✅ | ❌ | +| `PRIVATE` | Issued by an AWS Private CA | ✅ | ❌ | +| *(ACME origin)* | Issued through AWS's public ACME endpoint | ✅ | ❌ | + +The Inventory job sets `PrivateKeyEntry` from this `Type`, so Command only offers renewal on +certificates where it can actually succeed. + +> [!NOTE] +> **ACME-issued certificates have no subject DN.** AWS's public ACME CA issues SAN-only certificates +> with an empty subject, and ACM itself reports `Subject: ""`. Command derives `IssuedDN` / `IssuedCN` +> by parsing the uploaded certificate, so these appear with a blank common name and are findable +> only by SAN. This is a property of the certificates, not something the extension can correct. + +#### ACM tags + +The `ACM Tags` entry parameter uses comma-separated `Name1=Value1,Name2=Value2` pairs. + +- **On a new import**, tags are applied with the certificate. +- **On a renewal**, tags are **not** applied. This is an AWS constraint, not an extension choice: + the `ImportCertificate` API documents that tags cannot be applied when *reimporting* a certificate. + Applying them would take additional calls that could leave the certificate in an inconsistent + state if one of them failed, so the extension skips them. +- **Existing tags survive a renewal** — they are simply left alone. +- Inventory reads existing tags back onto the entry parameter. + +#### Inventory request behaviour + +ACM's `ListCertificates` filters are **inclusion** filters: anything not explicitly named is absent +from the result, with no error. The extension therefore requests every key algorithm and every key +pair origin that the AWS SDK defines, which is what makes SAN-less and ACME-origin certificates +visible. If a future AWS release adds a new algorithm or origin, certificates using it will be +silently missing until the extension is updated. + +Inventory makes two additional API calls per certificate (`GetCertificate` and +`ListTagsForCertificate`). ACM documents a request rate of roughly 10/second for each, so a store +with several thousand certificates will take a corresponding amount of time; this is an ACM +constraint rather than an extension one. + +#### Troubleshooting + +| Symptom | Likely cause | +|---|---| +| `AccessDenied` from a seemingly correct store | ACM permissions are on the originating identity instead of the [destination identity](#key-concept--the-destination-identity) | +| Assume Role fails | Destination role's [trust policy](#example-trust-policies) does not allow the originating principal, or `ExternalId` does not match | +| Store errors that need database access to fix | A `Secret`-type field was left empty — set unused secret fields to "No Value" | +| Discovery finds no stores | `organizations:ListAccounts` denied (check the log for the fallback warning), or the per-account role name does not match `DiscoveryRoleName` | +| Renewal fails on a specific certificate | The certificate's ACM `Type` is not `IMPORTED` — see [Certificate types and renewal](#certificate-types-and-renewal) | +| OAuth token rejected by STS | Token lifetime exceeds 12 hours, DPoP is enabled, or the provider requires credentials in the POST body rather than a Basic header | diff --git a/integration-manifest.json b/integration-manifest.json index 67565a6..53ff435 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -86,7 +86,7 @@ "DefaultValue": "https://***/oauth2/default/v1/token", "Required": false, "IsPAMEligible": false, - "Description": "An optional parameter sts:ExternalId to pass with Assume Role calls" + "Description": "The token endpoint URL of the OAuth 2.0 / OIDC identity provider, for example https://your-tenant.okta.com/oauth2/default/v1/token" }, { "Name": "OAuthClientId", diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh index 42f0708..a66440d 100755 --- a/scripts/store_types/bash/curl_create_store_types.sh +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -85,7 +85,7 @@ curl -s -X POST "https://${KEYFACTOR_HOSTNAME}/${KEYFACTOR_API_PATH}/Certificate "DefaultValue": "https://***/oauth2/default/v1/token", "Required": false, "IsPAMEligible": false, - "Description": "An optional parameter sts:ExternalId to pass with Assume Role calls" + "Description": "The token endpoint URL of the OAuth 2.0 / OIDC identity provider, for example https://your-tenant.okta.com/oauth2/default/v1/token" }, { "Name": "OAuthClientId", diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 index 9eeab29..92fd297 100644 --- a/scripts/store_types/powershell/restmethod_create_store_types.ps1 +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -85,7 +85,7 @@ $Body = @' "DefaultValue": "https://***/oauth2/default/v1/token", "Required": false, "IsPAMEligible": false, - "Description": "An optional parameter sts:ExternalId to pass with Assume Role calls" + "Description": "The token endpoint URL of the OAuth 2.0 / OIDC identity provider, for example https://your-tenant.okta.com/oauth2/default/v1/token" }, { "Name": "OAuthClientId", From 1d6fde94c4bf720e14490f5a18a49e819078767a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 26 Oct 2026 16:54:23 +0000 Subject: [PATCH 2/3] docs: auto-generate README and documentation [skip ci] --- README.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a029eca..8bb4872 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@

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

@@ -831,78 +831,91 @@ the Keyfactor Command Portal A switch to enable the store to use Default SDK credentials ![AWS-ACM-v3 Custom Field - UseDefaultSdkAuth](docsource/images/AWS-ACM-v3-custom-field-UseDefaultSdkAuth-dialog.svg) + ![AWS-ACM-v3 Custom Field - UseDefaultSdkAuth](docsource/images/AWS-ACM-v3-custom-field-UseDefaultSdkAuth-validation-options-dialog.svg) ###### Assume new Role using Default SDK Auth A switch to enable the store to assume a new Role when using Default SDK credentials ![AWS-ACM-v3 Custom Field - DefaultSdkAssumeRole](docsource/images/AWS-ACM-v3-custom-field-DefaultSdkAssumeRole-dialog.svg) + ![AWS-ACM-v3 Custom Field - DefaultSdkAssumeRole](docsource/images/AWS-ACM-v3-custom-field-DefaultSdkAssumeRole-validation-options-dialog.svg) ###### Use OAuth 2.0 Provider A switch to enable the store to use an OAuth provider workflow to authenticate with AWS ![AWS-ACM-v3 Custom Field - UseOAuth](docsource/images/AWS-ACM-v3-custom-field-UseOAuth-dialog.svg) + ![AWS-ACM-v3 Custom Field - UseOAuth](docsource/images/AWS-ACM-v3-custom-field-UseOAuth-validation-options-dialog.svg) ###### OAuth Scope This is the OAuth Scope needed for Okta OAuth, defined in Okta ![AWS-ACM-v3 Custom Field - OAuthScope](docsource/images/AWS-ACM-v3-custom-field-OAuthScope-dialog.svg) + ![AWS-ACM-v3 Custom Field - OAuthScope](docsource/images/AWS-ACM-v3-custom-field-OAuthScope-validation-options-dialog.svg) ###### OAuth Grant Type In OAuth 2.0, the term 'grant type' refers to the way an application gets an access token. In Okta this is `client_credentials` ![AWS-ACM-v3 Custom Field - OAuthGrantType](docsource/images/AWS-ACM-v3-custom-field-OAuthGrantType-dialog.svg) + ![AWS-ACM-v3 Custom Field - OAuthGrantType](docsource/images/AWS-ACM-v3-custom-field-OAuthGrantType-validation-options-dialog.svg) ###### OAuth Url The token endpoint URL of the OAuth 2.0 / OIDC identity provider, for example https://your-tenant.okta.com/oauth2/default/v1/token ![AWS-ACM-v3 Custom Field - OAuthUrl](docsource/images/AWS-ACM-v3-custom-field-OAuthUrl-dialog.svg) + ![AWS-ACM-v3 Custom Field - OAuthUrl](docsource/images/AWS-ACM-v3-custom-field-OAuthUrl-validation-options-dialog.svg) ###### OAuth Client ID The Client ID for OAuth. ![AWS-ACM-v3 Custom Field - OAuthClientId](docsource/images/AWS-ACM-v3-custom-field-OAuthClientId-dialog.svg) + ![AWS-ACM-v3 Custom Field - OAuthClientId](docsource/images/AWS-ACM-v3-custom-field-OAuthClientId-validation-options-dialog.svg) ###### OAuth Client Secret The Client Secret for OAuth. ![AWS-ACM-v3 Custom Field - OAuthClientSecret](docsource/images/AWS-ACM-v3-custom-field-OAuthClientSecret-dialog.svg) + ![AWS-ACM-v3 Custom Field - OAuthClientSecret](docsource/images/AWS-ACM-v3-custom-field-OAuthClientSecret-validation-options-dialog.svg) ###### Use IAM User Auth A switch to enable the store to use IAM User auth to assume a role when authenticating with AWS ![AWS-ACM-v3 Custom Field - UseIAM](docsource/images/AWS-ACM-v3-custom-field-UseIAM-dialog.svg) + ![AWS-ACM-v3 Custom Field - UseIAM](docsource/images/AWS-ACM-v3-custom-field-UseIAM-validation-options-dialog.svg) ###### IAM User Access Key The AWS Access Key for an IAM User ![AWS-ACM-v3 Custom Field - IAMUserAccessKey](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessKey-dialog.svg) + ![AWS-ACM-v3 Custom Field - IAMUserAccessKey](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessKey-validation-options-dialog.svg) ###### IAM User Access Secret The AWS Access Secret for an IAM User. ![AWS-ACM-v3 Custom Field - IAMUserAccessSecret](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessSecret-dialog.svg) + ![AWS-ACM-v3 Custom Field - IAMUserAccessSecret](docsource/images/AWS-ACM-v3-custom-field-IAMUserAccessSecret-validation-options-dialog.svg) ###### sts:ExternalId An optional parameter sts:ExternalId to pass with Assume Role calls ![AWS-ACM-v3 Custom Field - ExternalId](docsource/images/AWS-ACM-v3-custom-field-ExternalId-dialog.svg) + ![AWS-ACM-v3 Custom Field - ExternalId](docsource/images/AWS-ACM-v3-custom-field-ExternalId-validation-options-dialog.svg) ###### Discovery Role Name The IAM role name that exists in each target account. Used during cross-account Discovery to construct the Role ARN for cross-account access (arn:aws:iam:::role/). During a Discovery job this is supplied via the 'File name patterns to match' dialog field; this store property documents and defaults the value. ![AWS-ACM-v3 Custom Field - DiscoveryRoleName](docsource/images/AWS-ACM-v3-custom-field-DiscoveryRoleName-dialog.svg) + ![AWS-ACM-v3 Custom Field - DiscoveryRoleName](docsource/images/AWS-ACM-v3-custom-field-DiscoveryRoleName-validation-options-dialog.svg) ##### Entry Parameters Tab @@ -918,6 +931,7 @@ the Keyfactor Command Portal The optional ACM tags that should be assigned to the certificate. Multiple name/value pairs may be entered in the format of `Name1=Value1,Name2=Value2,...,NameN=ValueN` ![AWS-ACM-v3 Entry Parameter - ACM Tags](docsource/images/AWS-ACM-v3-entry-parameters-store-type-dialog-ACM Tags.svg) + ![AWS-ACM-v3 Entry Parameter - ACM Tags](docsource/images/AWS-ACM-v3-entry-parameters-store-type-dialog-ACM Tags-validation-options.svg) @@ -926,9 +940,9 @@ the Keyfactor Command Portal 1. **Download the latest AWS Certificate Manager (ACM) Universal Orchestrator extension from GitHub.** - Navigate to the [AWS Certificate Manager (ACM) Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/aws-acm-orchestrator-dev/releases/latest). Refer to the compatibility matrix below to determine which asset should be downloaded. Then, click the corresponding asset to download the zip archive. + Navigate to the [AWS Certificate Manager (ACM) Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/aws-orchestrator/releases/latest). Refer to the compatibility matrix below to determine which asset should be downloaded. Then, click the corresponding asset to download the zip archive. - | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `aws-acm-orchestrator-dev` .NET version to download | + | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `aws-orchestrator` .NET version to download | | --------- | ----------- | ----------- | ----------- | | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | | `11.6` _and_ newer | `net8.0` | | `net8.0` | @@ -945,10 +959,10 @@ the Keyfactor Command Portal 3. **Create a new directory for the AWS Certificate Manager (ACM) Universal Orchestrator extension inside the extensions directory.** - Create a new directory called `aws-acm-orchestrator-dev`. + Create a new directory called `aws-orchestrator`. > The directory name does not need to match any names used elsewhere; it just has to be unique within the extensions directory. -4. **Copy the contents of the downloaded and unzipped assemblies from __step 2__ to the `aws-acm-orchestrator-dev` directory.** +4. **Copy the contents of the downloaded and unzipped assemblies from __step 2__ to the `aws-orchestrator` directory.** 5. **Restart the Universal Orchestrator service.** From 307fcd613ee1bbdcc9923e938fac2a94fd28ac8d Mon Sep 17 00:00:00 2001 From: Mark Kachkaev Date: Mon, 26 Oct 2026 13:30:35 -0400 Subject: [PATCH 3/3] Test process build fix --- aws-acm-orchestrator.Tests/GetChainTests.cs | 15 ++++++++------- .../IsAcmCertificateArnTests.cs | 5 ++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/aws-acm-orchestrator.Tests/GetChainTests.cs b/aws-acm-orchestrator.Tests/GetChainTests.cs index 706f4c1..5fc53db 100644 --- a/aws-acm-orchestrator.Tests/GetChainTests.cs +++ b/aws-acm-orchestrator.Tests/GetChainTests.cs @@ -9,7 +9,6 @@ using System.Collections.Generic; using System.IO; using System.Text; -using FluentAssertions; using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs; using Org.BouncyCastle.OpenSsl; using Org.BouncyCastle.Pkcs; @@ -40,8 +39,8 @@ public void GetChain_LeafAndRoot_ReturnsOnlyTheRoot_NotTheLeaf() var subjects = ReadChainSubjects(store, KeyAlias); - subjects.Should().ContainSingle().Which.Should().Be("CN=Test Root CA"); - subjects.Should().NotContain("CN=Test Leaf"); + Assert.Equal("CN=Test Root CA", Assert.Single(subjects)); + Assert.DoesNotContain("CN=Test Leaf", subjects); } [Fact] @@ -58,8 +57,8 @@ public void GetChain_LeafIntermediateAndRoot_ReturnsIntermediateAndRoot_InOrder_ var subjects = ReadChainSubjects(store, KeyAlias); - subjects.Should().Equal("CN=Test Intermediate CA", "CN=Test Root CA"); - subjects.Should().NotContain("CN=Test Leaf"); + Assert.Equal(new[] { "CN=Test Intermediate CA", "CN=Test Root CA" }, subjects); + Assert.DoesNotContain("CN=Test Leaf", subjects); } [Fact] @@ -73,7 +72,8 @@ public void GetChain_LeafOnly_ReturnsNull() MemoryStream chainStream = Management.GetChain(store, KeyAlias); - chainStream.Should().BeNull("a leaf-only PFX has no intermediates, so the chain must be omitted rather than sent empty"); + // Null, not an empty stream: a leaf-only PFX has no intermediates, so the chain must be omitted. + Assert.Null(chainStream); } // Invokes the production GetChain and parses the PEM it produces back into subject DNs. @@ -81,7 +81,8 @@ private static List ReadChainSubjects(Pkcs12Store store, string alias) { using (MemoryStream chainStream = Management.GetChain(store, alias)) { - chainStream.Should().NotBeNull("a chain containing intermediates should produce PEM output"); + // A chain containing intermediates should produce PEM output. + Assert.NotNull(chainStream); string pem = Encoding.ASCII.GetString(chainStream.ToArray()); diff --git a/aws-acm-orchestrator.Tests/IsAcmCertificateArnTests.cs b/aws-acm-orchestrator.Tests/IsAcmCertificateArnTests.cs index b79e242..8f23b44 100644 --- a/aws-acm-orchestrator.Tests/IsAcmCertificateArnTests.cs +++ b/aws-acm-orchestrator.Tests/IsAcmCertificateArnTests.cs @@ -6,7 +6,6 @@ // 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 FluentAssertions; using Keyfactor.Extensions.Orchestrator.Aws.Acm.Jobs; using Xunit; @@ -27,7 +26,7 @@ public class IsAcmCertificateArnTests [InlineData("ARN:AWS:ACM:us-east-1:123456789012:certificate/abc")] // prefix match is case-insensitive public void IsAcmCertificateArn_AcmCertificateArns_ReturnTrue(string alias) { - Management.IsAcmCertificateArn(alias).Should().BeTrue(); + Assert.True(Management.IsAcmCertificateArn(alias)); } [Theory] @@ -40,7 +39,7 @@ public void IsAcmCertificateArn_AcmCertificateArns_ReturnTrue(string alias) [InlineData("arn:aws:acm:us-east-1:123456789012:certificate-authority/abc")] // ACM PCA-style, not a certificate public void IsAcmCertificateArn_NonAcmCertificateAliases_ReturnFalse(string alias) { - Management.IsAcmCertificateArn(alias).Should().BeFalse(); + Assert.False(Management.IsAcmCertificateArn(alias)); } } }