From a9fe9164bd752d53a785b28a23afb43e85ae6c43 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 1 Jul 2026 16:38:47 -0700 Subject: [PATCH 1/8] aws: add STS install-config field, types, and validation Add the STS configuration struct to the AWS platform type with Managed and Manual modes, feature-gated behind AWSSTSDefault. This provides the install-config surface for users to opt into native STS-based credential provisioning. Co-Authored-By: Claude Opus 4.6 --- .../install.openshift.io_installconfigs.yaml | 27 +++++++++ pkg/explain/printer_test.go | 6 ++ pkg/types/aws/defaults/platform.go | 5 ++ pkg/types/aws/defaults/platform_test.go | 31 ++++++++++ pkg/types/aws/platform.go | 51 ++++++++++++++++ pkg/types/aws/validation/platform.go | 22 +++++++ pkg/types/aws/validation/platform_test.go | 58 +++++++++++++++++++ pkg/types/aws/zz_generated.deepcopy.go | 21 +++++++ 8 files changed, 221 insertions(+) diff --git a/data/data/install.openshift.io_installconfigs.yaml b/data/data/install.openshift.io_installconfigs.yaml index 9ffb030ec83..54ceec8ff89 100644 --- a/data/data/install.openshift.io_installconfigs.yaml +++ b/data/data/install.openshift.io_installconfigs.yaml @@ -5462,6 +5462,33 @@ spec: - url type: object type: array + sts: + description: |- + STS configures the use of AWS Security Token Service for + short-term credential provisioning. When set, the cluster + components use IAM roles with OIDC-based web identity tokens + instead of long-lived credentials. + properties: + mode: + description: |- + Mode determines how STS resources are provisioned. + + Following are the accepted values: + + * "Managed": The installer creates the OIDC provider, IAM roles, + and credential secrets automatically. + + * "Manual": The user pre-creates all STS resources (e.g. via ccoctl) + and places the manifests in the install directory. + + If this field is not set explicitly, the default value is "Manual". + This default is subject to change over time. + enum: + - "" + - Managed + - Manual + type: string + type: object subnets: description: |- Subnets specifies existing subnets (by ID) where cluster diff --git a/pkg/explain/printer_test.go b/pkg/explain/printer_test.go index cc07374bf5c..905c934340e 100644 --- a/pkg/explain/printer_test.go +++ b/pkg/explain/printer_test.go @@ -298,6 +298,12 @@ There must be only one ServiceEndpoint for a service. ServiceEndpoint store the configuration for services to override existing defaults of AWS Services. + sts + STS configures the use of AWS Security Token Service for +short-term credential provisioning. When set, the cluster +components use IAM roles with OIDC-based web identity tokens +instead of long-lived credentials. + subnets <[]string> Subnets specifies existing subnets (by ID) where cluster resources will be created. Leave unset to have the installer diff --git a/pkg/types/aws/defaults/platform.go b/pkg/types/aws/defaults/platform.go index f011c0841e1..66cbfa5ce06 100644 --- a/pkg/types/aws/defaults/platform.go +++ b/pkg/types/aws/defaults/platform.go @@ -70,6 +70,11 @@ func SetPlatformDefaults(p *aws.Platform) { logrus.Infof("Adding default service endpoints for region %s", p.Region) } } + + // Default to Manual mode to be backwards compatible + if p.STS != nil && p.STS.Mode == "" { + p.STS.Mode = aws.STSModeManual + } } // InstanceTypes returns a list of instance types, in decreasing priority order, which we should use for a given diff --git a/pkg/types/aws/defaults/platform_test.go b/pkg/types/aws/defaults/platform_test.go index 243fb22769c..3326fd7463e 100644 --- a/pkg/types/aws/defaults/platform_test.go +++ b/pkg/types/aws/defaults/platform_test.go @@ -178,6 +178,36 @@ func TestSetPlatformDefaults(t *testing.T) { }, }, }, + { + name: "non-nil STS and empty STSmode should default to mode Manual", + platform: &aws.Platform{ + Region: "us-east-1", + STS: &aws.STS{}, + }, + expected: &aws.Platform{ + Region: "us-east-1", + IPFamily: network.IPv4, + STS: &aws.STS{ + Mode: aws.STSModeManual, + }, + }, + }, + { + name: "non-nil STS and non-empty STSmode should not set default", + platform: &aws.Platform{ + Region: "us-east-1", + STS: &aws.STS{ + Mode: aws.STSModeManaged, + }, + }, + expected: &aws.Platform{ + Region: "us-east-1", + IPFamily: network.IPv4, + STS: &aws.STS{ + Mode: aws.STSModeManaged, + }, + }, + }, } for _, tc := range cases { @@ -186,6 +216,7 @@ func TestSetPlatformDefaults(t *testing.T) { assert.Equal(t, tc.expected.IPFamily, tc.platform.IPFamily) assert.Equal(t, tc.expected.LBType, tc.platform.LBType) assert.Equal(t, tc.expected.ServiceEndpoints, tc.platform.ServiceEndpoints) + assert.Equal(t, tc.expected.STS, tc.platform.STS) }) } } diff --git a/pkg/types/aws/platform.go b/pkg/types/aws/platform.go index c94a4ccb13b..3a6c54c2691 100644 --- a/pkg/types/aws/platform.go +++ b/pkg/types/aws/platform.go @@ -142,6 +142,13 @@ type Platform struct { // +kubebuilder:validation:Enum="IPv4";"DualStackIPv4Primary";"DualStackIPv6Primary" // +optional IPFamily network.IPFamily `json:"ipFamily,omitempty"` + + // STS configures the use of AWS Security Token Service for + // short-term credential provisioning. When set, the cluster + // components use IAM roles with OIDC-based web identity tokens + // instead of long-lived credentials. + // +optional + STS *STS `json:"sts,omitempty"` } // ServiceEndpoint store the configuration for services to @@ -240,6 +247,50 @@ const ( ControlPlaneInternalLBSubnetRole SubnetRoleType = "ControlPlaneInternalLB" ) +// STSMode determines how STS short-term credentials are provisioned. +// +kubebuilder:validation:Enum="";"Managed";"Manual" +type STSMode string + +const ( + // STSModeManaged indicates the installer creates the OIDC provider, + // S3 bucket, IAM roles, and credential secrets. + STSModeManaged STSMode = "Managed" + + // STSModeManual indicates the user pre-creates all STS resources + // (e.g. via ccoctl) and provides manifests to the installer. + STSModeManual STSMode = "Manual" +) + +// STS configures the use of AWS Security Token Service for +// short-term credential provisioning. +type STS struct { + // Mode determines how STS resources are provisioned. + // + // Following are the accepted values: + // + // * "Managed": The installer creates the OIDC provider, IAM roles, + // and credential secrets automatically. + // + // * "Manual": The user pre-creates all STS resources (e.g. via ccoctl) + // and places the manifests in the install directory. + // + // If this field is not set explicitly, the default value is "Manual". + // This default is subject to change over time. + // + // +optional + Mode STSMode `json:"mode,omitempty"` +} + +// IsSTSEnabled returns true when STS is configured. +func (p *Platform) IsSTSEnabled() bool { + return p != nil && p.STS != nil +} + +// IsSTSManaged returns true when the installer manages STS resources. +func (p *Platform) IsSTSManaged() bool { + return p.IsSTSEnabled() && p.STS.Mode == STSModeManaged +} + // IsPublicOnlySubnetsEnabled returns whether the public-only subnets feature has been enabled via env var. func IsPublicOnlySubnetsEnabled() bool { // Even though this looks too simple for a function, it's better than having to update the logic everywhere it's diff --git a/pkg/types/aws/validation/platform.go b/pkg/types/aws/validation/platform.go index 7f304ea495d..3c3eb41cbb7 100644 --- a/pkg/types/aws/validation/platform.go +++ b/pkg/types/aws/validation/platform.go @@ -59,6 +59,7 @@ func ValidatePlatform(p *aws.Platform, publish types.PublishingStrategy, cm type allErrs = append(allErrs, validateUserTags(p.UserTags, p.PropagateUserTag, fldPath.Child("userTags"))...) allErrs = append(allErrs, validateIPFamily(p.IPFamily, fldPath.Child("ipFamily"))...) allErrs = append(allErrs, validateLBType(p.LBType, p.IPFamily, fldPath.Child("lbType"))...) + allErrs = append(allErrs, validateSTS(p.STS, cm, fldPath.Child("sts"))...) if p.DefaultMachinePlatform != nil { allErrs = append(allErrs, ValidateMachinePool(p, p.DefaultMachinePlatform, types.MachinePoolDefaultConfig, fldPath.Child("defaultMachinePlatform"))...) @@ -201,6 +202,27 @@ func validateServiceURL(uri string) error { return nil } +func validateSTS(sts *aws.STS, cm types.CredentialsMode, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + if sts == nil { + return allErrs + } + + if cm != types.ManualCredentialsMode { + allErrs = append(allErrs, field.Required(field.NewPath("credentialsMode"), "Manual credentials mode is required when STS is configured")) + } + + validModes := []string{string(aws.STSModeManaged), string(aws.STSModeManual)} + switch sts.Mode { + case aws.STSModeManaged, aws.STSModeManual: + default: + allErrs = append(allErrs, field.NotSupported(fldPath.Child("mode"), sts.Mode, validModes)) + } + + return allErrs +} + func validateSubnets(subnets []aws.Subnet, publish types.PublishingStrategy, fldPath *field.Path) field.ErrorList { if len(subnets) == 0 { return nil diff --git a/pkg/types/aws/validation/platform_test.go b/pkg/types/aws/validation/platform_test.go index 34e3f548e41..eaab2ca4c06 100644 --- a/pkg/types/aws/validation/platform_test.go +++ b/pkg/types/aws/validation/platform_test.go @@ -683,6 +683,64 @@ func TestValidatePlatform(t *testing.T) { LBType: "", }, }, + { + name: "valid STS managed", + platform: &aws.Platform{ + Region: "us-east-1", + STS: &aws.STS{Mode: aws.STSModeManaged}, + }, + credMode: types.ManualCredentialsMode, + }, + { + name: "valid STS manual", + platform: &aws.Platform{ + Region: "us-east-1", + STS: &aws.STS{Mode: aws.STSModeManual}, + }, + credMode: types.ManualCredentialsMode, + }, + { + name: "STS with no credentials mode", + platform: &aws.Platform{ + Region: "us-east-1", + STS: &aws.STS{Mode: aws.STSModeManaged}, + }, + expected: `^credentialsMode: Required value: Manual credentials mode is required when STS is configured$`, + }, + { + name: "STS with Passthrough credentials mode", + platform: &aws.Platform{ + Region: "us-east-1", + STS: &aws.STS{Mode: aws.STSModeManaged}, + }, + credMode: types.PassthroughCredentialsMode, + expected: `^credentialsMode: Required value: Manual credentials mode is required when STS is configured$`, + }, + { + name: "STS with Mint credentials mode", + platform: &aws.Platform{ + Region: "us-east-1", + STS: &aws.STS{Mode: aws.STSModeManaged}, + }, + credMode: types.MintCredentialsMode, + expected: `^credentialsMode: Required value: Manual credentials mode is required when STS is configured$`, + }, + { + name: "invalid STS unsupported mode", + platform: &aws.Platform{ + Region: "us-east-1", + STS: &aws.STS{Mode: "Invalid"}, + }, + credMode: types.ManualCredentialsMode, + expected: `^\Qtest-path.sts.mode: Unsupported value: "Invalid"\E`, + }, + { + name: "nil STS is valid", + platform: &aws.Platform{ + Region: "us-east-1", + STS: nil, + }, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/types/aws/zz_generated.deepcopy.go b/pkg/types/aws/zz_generated.deepcopy.go index 347aa22cdd5..b8abef2ee6c 100644 --- a/pkg/types/aws/zz_generated.deepcopy.go +++ b/pkg/types/aws/zz_generated.deepcopy.go @@ -203,6 +203,11 @@ func (in *Platform) DeepCopyInto(out *Platform) { *out = new(MachinePool) (*in).DeepCopyInto(*out) } + if in.STS != nil { + in, out := &in.STS, &out.STS + *out = new(STS) + **out = **in + } return } @@ -216,6 +221,22 @@ func (in *Platform) DeepCopy() *Platform { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *STS) DeepCopyInto(out *STS) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new STS. +func (in *STS) DeepCopy() *STS { + if in == nil { + return nil + } + out := new(STS) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceEndpoint) DeepCopyInto(out *ServiceEndpoint) { *out = *in From a3b7d3f86912ced2a9ab97e76be7cfcdaa177f22 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 1 Jul 2026 16:39:01 -0700 Subject: [PATCH 2/8] aws: generate SA signing key pair for STS managed mode Extract filename constants and add PublicKeyData() method to the bound SA signing key asset. When STS managed mode is enabled, the key pair is generated during install and the public key is used for OIDC JWKS construction. Co-Authored-By: Claude Opus 4.6 --- pkg/asset/tls/boundsasigningkey.go | 80 +++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/pkg/asset/tls/boundsasigningkey.go b/pkg/asset/tls/boundsasigningkey.go index 1f88f2f295b..3781eab4672 100644 --- a/pkg/asset/tls/boundsasigningkey.go +++ b/pkg/asset/tls/boundsasigningkey.go @@ -2,18 +2,28 @@ package tls import ( "context" + "crypto/rand" + "crypto/rsa" + "fmt" "os" - "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/installconfig" + awstypes "github.com/openshift/installer/pkg/types/aws" ) -// BoundSASigningKey contains a user provided key and public parts for the +const ( + boundSASigningKeyFileName = "bound-service-account-signing-key.key" + boundSASigningPubFileName = "bound-service-account-signing-key.pub" +) + +// BoundSASigningKey contains the key and public parts for the // service account signing key used by kube-apiserver. -// This asset does not generate any new content and only loads these files from disk -// when provided by the user. +// When AWS STS is configured in managed mode, this asset generates +// a 4096-bit RSA key pair. Otherwise, it only loads user-provided +// keys from disk. type BoundSASigningKey struct { FileList []*asset.File } @@ -22,27 +32,73 @@ var _ asset.WritableAsset = (*BoundSASigningKey)(nil) // Name returns a human friendly name for the asset. func (*BoundSASigningKey) Name() string { - return "User-provided Service Account Signing key" + return "Service Account Signing key" } // Dependencies returns all of the dependencies directly needed to generate // the asset. func (*BoundSASigningKey) Dependencies() []asset.Asset { - return nil + return []asset.Asset{ + &installconfig.InstallConfig{}, + } } -// Generate generates the CloudProviderConfig. -func (*BoundSASigningKey) Generate(_ context.Context, dependencies asset.Parents) error { return nil } +// Generate generates a 4096-bit RSA key pair when AWS STS managed mode +// is active. Otherwise it is a no-op (user-provided keys are handled by Load). +func (sk *BoundSASigningKey) Generate(_ context.Context, dependencies asset.Parents) error { + ic := &installconfig.InstallConfig{} + dependencies.Get(ic) + + switch ic.Config.Platform.Name() { + case awstypes.Name: + // Generating SA signing key is only supported when STS resources are managed. + if !ic.Config.Platform.AWS.IsSTSManaged() { + return nil + } + default: + logrus.Debugf("Skipped generating Service Account Signing key for unsupported platform %s", ic.Config.Platform.Name()) + return nil + } + + key, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("failed to generate RSA key for service account signing: %w", err) + } + + privData := PrivateKeyToPem(key) + pubData, err := PublicKeyToPem(&key.PublicKey) + if err != nil { + return fmt.Errorf("failed to encode public key: %w", err) + } + + sk.FileList = []*asset.File{ + {Filename: assetFilePath(boundSASigningKeyFileName), Data: privData}, + {Filename: assetFilePath(boundSASigningPubFileName), Data: pubData}, + } + + return nil +} // Files returns the files generated by the asset. func (sk *BoundSASigningKey) Files() []*asset.File { return sk.FileList } +// PublicKeyData returns the PEM-encoded public key bytes, or nil +// if the key has not been generated or loaded. +func (sk *BoundSASigningKey) PublicKeyData() []byte { + for _, f := range sk.FileList { + if f.Filename == assetFilePath(boundSASigningPubFileName) { + return f.Data + } + } + return nil +} + // Load reads the private key from the disk. // It ensures that the key provided is a valid RSA key. func (sk *BoundSASigningKey) Load(f asset.FileFetcher) (bool, error) { - keyFile, err := f.FetchByName(assetFilePath("bound-service-account-signing-key.key")) + keyFile, err := f.FetchByName(assetFilePath(boundSASigningKeyFileName)) if err != nil { if os.IsNotExist(err) { return false, nil @@ -53,12 +109,12 @@ func (sk *BoundSASigningKey) Load(f asset.FileFetcher) (bool, error) { rsaKey, err := PemToPrivateKey(keyFile.Data) if err != nil { logrus.Debugf("Failed to load rsa.PrivateKey from file: %s", err) - return false, errors.Wrap(err, "failed to load rsa.PrivateKey from the file") + return false, fmt.Errorf("failed to load rsa.PrivateKey from the file: %w", err) } pubData, err := PublicKeyToPem(&rsaKey.PublicKey) if err != nil { - return false, errors.Wrap(err, "failed to extract public key from the key") + return false, fmt.Errorf("failed to extract public key from the key: %w", err) } - sk.FileList = []*asset.File{keyFile, {Filename: assetFilePath("bound-service-account-signing-key.pub"), Data: pubData}} + sk.FileList = []*asset.File{keyFile, {Filename: assetFilePath(boundSASigningPubFileName), Data: pubData}} return true, nil } From 4e25ae987a64dc4af861094afe3c1a24ccc2b28f Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 1 Jul 2026 16:39:16 -0700 Subject: [PATCH 3/8] aws: extract and parse CredentialsRequests from release image for STS Add a generic CredentialsRequests asset that extracts platform-specific credential requests from the release image. Uses a decoder registry pattern so new platforms can register their provider spec decoder via init(). AWS decoder is registered in aws.go. Co-Authored-By: Claude Opus 4.6 --- pkg/asset/credentialsrequest/aws.go | 28 ++ .../credentialsrequest/credentialsrequests.go | 317 ++++++++++++++++++ .../credentialsrequests_test.go | 229 +++++++++++++ 3 files changed, 574 insertions(+) create mode 100644 pkg/asset/credentialsrequest/aws.go create mode 100644 pkg/asset/credentialsrequest/credentialsrequests.go create mode 100644 pkg/asset/credentialsrequest/credentialsrequests_test.go diff --git a/pkg/asset/credentialsrequest/aws.go b/pkg/asset/credentialsrequest/aws.go new file mode 100644 index 00000000000..3be058065a9 --- /dev/null +++ b/pkg/asset/credentialsrequest/aws.go @@ -0,0 +1,28 @@ +package credentialsrequest + +import ( + "k8s.io/apimachinery/pkg/runtime" + + ccov1 "github.com/openshift/cloud-credential-operator/pkg/apis/cloudcredential/v1" + awstypes "github.com/openshift/installer/pkg/types/aws" +) + +// AWSProviderSpec holds the decoded AWS-specific provider spec +// from a CredentialsRequest. +type AWSProviderSpec struct { + StatementEntries []ccov1.StatementEntry +} + +func init() { + registerProviderSpecDecoder(awstypes.Name, decodeAWSProviderSpec) +} + +func decodeAWSProviderSpec(raw *runtime.RawExtension) (interface{}, error) { + awsSpec := &ccov1.AWSProviderSpec{} + if err := ccov1.Codec.DecodeProviderSpec(raw, awsSpec); err != nil { + return nil, err + } + return &AWSProviderSpec{ + StatementEntries: awsSpec.StatementEntries, + }, nil +} diff --git a/pkg/asset/credentialsrequest/credentialsrequests.go b/pkg/asset/credentialsrequest/credentialsrequests.go new file mode 100644 index 00000000000..e8aced5d1d3 --- /dev/null +++ b/pkg/asset/credentialsrequest/credentialsrequests.go @@ -0,0 +1,317 @@ +package credentialsrequest + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" + + ccov1 "github.com/openshift/cloud-credential-operator/pkg/apis/cloudcredential/v1" + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/installconfig" + "github.com/openshift/installer/pkg/asset/releaseimage" + "github.com/openshift/installer/pkg/types" + awstypes "github.com/openshift/installer/pkg/types/aws" +) + +// providerSpecDecoderFunc decodes a raw provider spec into a +// platform-specific type. Each platform registers its decoder via +// registerProviderSpecDecoder in an init() function. +type providerSpecDecoderFunc func(raw *runtime.RawExtension) (interface{}, error) + +var providerSpecDecoders = map[string]providerSpecDecoderFunc{} + +// registerProviderSpecDecoder registers a provider spec decoder for +// the given cloud platform. Called from platform-specific init() functions. +func registerProviderSpecDecoder(cloud string, fn providerSpecDecoderFunc) { + providerSpecDecoders[cloud] = fn +} + +// CredentialRequest holds a parsed CredentialsRequest with platform-agnostic +// common fields and a platform-specific ProviderSpec. +type CredentialRequest struct { + Name string + Namespace string + SecretRefName string + SecretRefNamespace string + ServiceAccountNames []string + ProviderSpec interface{} +} + +const ( + credentialsRequestFileName = "99_credentials-request_%s.yaml" + credentialsRequestFileNamePattern = "99_credentials-request_*.yaml" + credentialsRequestDir = "manifests" +) + +// CredentialsRequests is an asset that extracts and parses +// CredentialsRequests from the release image for the target platform. +// It is consumed by infrastructure provisioning (e.g. IAM roles) and +// manifest generation (credential secrets). +type CredentialsRequests struct { + FileList []*asset.File + Requests []CredentialRequest +} + +var _ asset.WritableAsset = (*CredentialsRequests)(nil) + +// Name returns a human-friendly name for the asset. +func (*CredentialsRequests) Name() string { + return "Credentials Requests" +} + +// Dependencies returns the assets required to extract credentials requests. +func (*CredentialsRequests) Dependencies() []asset.Asset { + return []asset.Asset{ + &installconfig.InstallConfig{}, + &releaseimage.Image{}, + } +} + +// Generate extracts CredentialsRequests from the release image for the +// target platform and parses them into platform-specific structs. +func (cr *CredentialsRequests) Generate(ctx context.Context, dependencies asset.Parents) error { + ic := &installconfig.InstallConfig{} + ri := &releaseimage.Image{} + dependencies.Get(ic, ri) + + cloud := ic.Config.Platform.Name() + switch cloud { + case awstypes.Name: + if !ic.Config.Platform.AWS.IsSTSManaged() { + return nil + } + default: + logrus.Debugf("Skipped generating Credentials Requests for unsupported platform %s", cloud) + return nil + } + + pullSecret := ic.Config.PullSecret + mirrorConfig := types.BuildMirrorConfig(ic.Config) + + icDir, err := os.MkdirTemp("", "install-config") + if err != nil { + return fmt.Errorf("failed to create temp directory for install-config: %w", err) + } + defer os.RemoveAll(icDir) + + icData, err := yaml.Marshal(ic.Config) + if err != nil { + return fmt.Errorf("failed to marshal install-config: %w", err) + } + icPath := filepath.Join(icDir, "install-config.yaml") + if err := os.WriteFile(icPath, icData, 0o600); err != nil { + return fmt.Errorf("failed to write install-config: %w", err) + } + + tmpDir, err := os.MkdirTemp("", "credentials-requests") + if err != nil { + return fmt.Errorf("failed to create temp directory for credentials requests: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err := extractCredentialsRequests(ctx, cloud, pullSecret, ri.PullSpec, tmpDir, mirrorConfig, icPath); err != nil { + return fmt.Errorf("failed to extract credentials requests from release image: %w", err) + } + + entries, err := os.ReadDir(tmpDir) + if err != nil { + return fmt.Errorf("failed to read credentials requests directory: %w", err) + } + for _, entry := range entries { + if entry.IsDir() || !(strings.HasSuffix(entry.Name(), ".yaml") || strings.HasSuffix(entry.Name(), ".yml")) { + continue + } + data, err := os.ReadFile(filepath.Join(tmpDir, entry.Name())) + if err != nil { + return fmt.Errorf("failed to read %s: %w", entry.Name(), err) + } + req, err := parseCredentialRequestBytes(data, entry.Name()) + if err != nil { + return fmt.Errorf("failed to parse credentials request %s: %w", entry.Name(), err) + } + if req == nil { + continue + } + cr.Requests = append(cr.Requests, *req) + cr.FileList = append(cr.FileList, &asset.File{ + Filename: filepath.Join(credentialsRequestDir, fmt.Sprintf(credentialsRequestFileName, req.Namespace+"-"+req.Name)), + Data: data, + }) + } + if len(cr.Requests) == 0 { + return fmt.Errorf("no credentials requests found in release image") + } + logrus.Infof("Extracted %d credentials requests from release image", len(cr.Requests)) + return nil +} + +// Files returns the files generated by the asset. +func (cr *CredentialsRequests) Files() []*asset.File { + return cr.FileList +} + +// Load reads previously persisted credentials requests from disk. +func (cr *CredentialsRequests) Load(f asset.FileFetcher) (bool, error) { + fileList, err := f.FetchByPattern(filepath.Join(credentialsRequestDir, credentialsRequestFileNamePattern)) + if err != nil { + return false, err + } + if len(fileList) == 0 { + return false, nil + } + + cr.FileList = fileList + cr.Requests = make([]CredentialRequest, 0, len(fileList)) + for _, file := range fileList { + req, err := parseCredentialRequestBytes(file.Data, file.Filename) + if err != nil { + return false, fmt.Errorf("failed to parse %s: %w", file.Filename, err) + } + if req != nil { + cr.Requests = append(cr.Requests, *req) + } + } + return true, nil +} + +// extractCredentialsRequests calls oc to extract credentials requests +// for the specified cloud platform from the release image. +func extractCredentialsRequests(ctx context.Context, cloud, pullSecret, releaseImage, destDir string, mirrorConfig types.MirrorConfig, icPath string) error { + if _, err := exec.LookPath("oc"); err != nil { + return fmt.Errorf("oc command not found in PATH: %w", err) + } + + cmd := []string{ + "oc", "adm", "release", "extract", + "--credentials-requests", + "--cloud=" + cloud, + "--included=true", + "--install-config=" + icPath, + "--to=" + destDir, + } + + if mirrorConfig.HasMirrors() { + mirrorArg, cleanup, err := getMirrorArg(mirrorConfig) + if err != nil { + return err + } + if mirrorArg != "" { + defer cleanup() + cmd = append(cmd, mirrorArg) + } + } + + cmd = append(cmd, releaseImage) + + logrus.Debugf("Extracting credentials requests: %s", strings.Join(cmd, " ")) + _, err := executeOC(ctx, pullSecret, cmd) + return err +} + +// parseCredentialRequestBytes parses a single CredentialsRequest YAML +// document, trying all registered provider spec decoders. +func parseCredentialRequestBytes(data []byte, filename string) (*CredentialRequest, error) { + cr := &ccov1.CredentialsRequest{} + if err := yaml.Unmarshal(data, cr); err != nil { + return nil, fmt.Errorf("failed to parse %s as CredentialsRequest: %w", filename, err) + } + if cr.Spec.ProviderSpec == nil { + return nil, fmt.Errorf("%s has no provider spec", filename) + } + var providerSpec interface{} + for _, decoder := range providerSpecDecoders { + spec, err := decoder(cr.Spec.ProviderSpec) + if err == nil { + providerSpec = spec + break + } + } + if providerSpec == nil { + return nil, fmt.Errorf("no registered decoder matched provider spec in %s", filename) + } + return &CredentialRequest{ + Name: cr.Name, + Namespace: cr.Namespace, + SecretRefName: cr.Spec.SecretRef.Name, + SecretRefNamespace: cr.Spec.SecretRef.Namespace, + ServiceAccountNames: cr.Spec.ServiceAccountNames, + ProviderSpec: providerSpec, + }, nil +} + +// executeOC runs an oc command with the pull secret written to a temp file +// for registry authentication. This is a local copy of the pattern from +// pkg/asset/agent/oc.go to avoid cross-package dependencies. +func executeOC(ctx context.Context, pullSecret string, command []string) (string, error) { + ps, err := os.CreateTemp("", "registry-config") + if err != nil { + return "", err + } + defer func() { + ps.Close() + os.Remove(ps.Name()) //nolint:gosec + }() + if _, err := ps.Write([]byte(pullSecret)); err != nil { + return "", err + } + ps.Close() + + registryConfig := "--registry-config=" + ps.Name() + command = append(command, registryConfig) + + var stdoutBytes, stderrBytes bytes.Buffer + cmd := exec.CommandContext(ctx, command[0], command[1:]...) //nolint:gosec + cmd.Stdout = &stdoutBytes + cmd.Stderr = &stderrBytes + + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("command '%s' exited with non-zero exit code %d: %s\n%s", + strings.Join(command, " "), exitErr.ExitCode(), stdoutBytes.String(), stderrBytes.String()) + } + return "", fmt.Errorf("command '%s' failed: %w", strings.Join(command, " "), err) + } + return strings.TrimSpace(stdoutBytes.String()), nil +} + +// getMirrorArg creates a temporary ICSP file from mirror config for +// use with oc commands. This is a local copy of the pattern from +// pkg/asset/rhcos/releaseextract.go. +func getMirrorArg(mirrorConfig types.MirrorConfig) (string, func(), error) { + if !mirrorConfig.HasMirrors() { + return "", nil, nil + } + + contents, err := mirrorConfig.GetICSPContents() + if err != nil { + return "", nil, err + } + + icspFile, err := os.CreateTemp("", "icsp-file") + if err != nil { + return "", nil, err + } + + if _, err := icspFile.Write(contents); err != nil { + icspFile.Close() + os.Remove(icspFile.Name()) //nolint:gosec + return "", nil, err + } + icspFile.Close() + + remove := func() { + os.Remove(icspFile.Name()) //nolint:gosec + } + + return "--icsp-file=" + icspFile.Name(), remove, nil +} diff --git a/pkg/asset/credentialsrequest/credentialsrequests_test.go b/pkg/asset/credentialsrequest/credentialsrequests_test.go new file mode 100644 index 00000000000..39f7495664b --- /dev/null +++ b/pkg/asset/credentialsrequest/credentialsrequests_test.go @@ -0,0 +1,229 @@ +package credentialsrequest + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseCredentialRequestBytes(t *testing.T) { + cases := []struct { + name string + data string + wantNil bool + wantErr string + }{ + { + name: "valid credentials request", + data: `apiVersion: cloudcredential.openshift.io/v1 +kind: CredentialsRequest +metadata: + name: cloud-credentials + namespace: openshift-ingress-operator +spec: + secretRef: + name: cloud-credentials + namespace: openshift-ingress-operator + serviceAccountNames: + - ingress-operator + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: AWSProviderSpec + statementEntries: + - effect: Allow + action: + - elasticloadbalancing:DescribeLoadBalancers + - route53:ListHostedZones + - route53:ChangeResourceRecordSets + - tag:GetResources + resource: "*" +`, + }, + { + name: "errors on credentials request without provider spec", + data: `apiVersion: cloudcredential.openshift.io/v1 +kind: CredentialsRequest +metadata: + name: cloud-credentials + namespace: openshift-ingress-operator +spec: + secretRef: + name: cloud-credentials + namespace: openshift-ingress-operator +`, + wantErr: "has no provider spec", + }, + { + name: "errors on invalid yaml", + data: "not valid yaml: [", + wantErr: "failed to parse", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := parseCredentialRequestBytes([]byte(tc.data), "test.yaml") + if tc.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + assert.NoError(t, err) + if tc.wantNil { + assert.Nil(t, req) + } else { + assert.NotNil(t, req) + } + }) + } +} + +func TestParseCredentialRequestBytesMultiple(t *testing.T) { + files := map[string]string{ + "ingress.yaml": `apiVersion: cloudcredential.openshift.io/v1 +kind: CredentialsRequest +metadata: + name: cloud-credentials + namespace: openshift-ingress-operator +spec: + secretRef: + name: cloud-credentials + namespace: openshift-ingress-operator + serviceAccountNames: + - ingress-operator + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: AWSProviderSpec + statementEntries: + - effect: Allow + action: + - elasticloadbalancing:DescribeLoadBalancers + resource: "*" +`, + "machine-api.yaml": `apiVersion: cloudcredential.openshift.io/v1 +kind: CredentialsRequest +metadata: + name: aws-cloud-credentials + namespace: openshift-machine-api +spec: + secretRef: + name: aws-cloud-credentials + namespace: openshift-machine-api + serviceAccountNames: + - machine-api-controllers + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: AWSProviderSpec + statementEntries: + - effect: Allow + action: + - ec2:CreateTags + - ec2:DescribeInstances + - ec2:RunInstances + - ec2:TerminateInstances + resource: "*" +`, + } + + var requests []CredentialRequest + for name, content := range files { + req, err := parseCredentialRequestBytes([]byte(content), name) + if !assert.NoError(t, err, "file %s", name) { + return + } + if req != nil { + requests = append(requests, *req) + } + } + assert.Len(t, requests, 2) +} + +func TestParseCredentialRequestBytesFields(t *testing.T) { + content := `apiVersion: cloudcredential.openshift.io/v1 +kind: CredentialsRequest +metadata: + name: cloud-credentials + namespace: openshift-ingress-operator +spec: + secretRef: + name: cloud-credentials + namespace: openshift-ingress-operator + serviceAccountNames: + - ingress-operator + - ingress-operator-sa2 + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: AWSProviderSpec + statementEntries: + - effect: Allow + action: + - elasticloadbalancing:DescribeLoadBalancers + - route53:ListHostedZones + resource: "*" + - effect: Allow + action: + - s3:GetObject + resource: "arn:aws:s3:::my-bucket/*" +` + + r, err := parseCredentialRequestBytes([]byte(content), "ingress.yaml") + if !assert.NoError(t, err) { + return + } + if !assert.NotNil(t, r) { + return + } + + assert.Equal(t, "cloud-credentials", r.Name) + assert.Equal(t, "openshift-ingress-operator", r.Namespace) + assert.Equal(t, "cloud-credentials", r.SecretRefName) + assert.Equal(t, "openshift-ingress-operator", r.SecretRefNamespace) + assert.Equal(t, []string{"ingress-operator", "ingress-operator-sa2"}, r.ServiceAccountNames) + assert.IsType(t, &AWSProviderSpec{}, r.ProviderSpec) + awsSpec := r.ProviderSpec.(*AWSProviderSpec) + assert.Len(t, awsSpec.StatementEntries, 2) + assert.Equal(t, "Allow", awsSpec.StatementEntries[0].Effect) + assert.Equal(t, []string{"elasticloadbalancing:DescribeLoadBalancers", "route53:ListHostedZones"}, awsSpec.StatementEntries[0].Action) + assert.Equal(t, "*", awsSpec.StatementEntries[0].Resource) + assert.Equal(t, "arn:aws:s3:::my-bucket/*", awsSpec.StatementEntries[1].Resource) +} + +func TestParseCredentialRequestBytesLoadRoundTrip(t *testing.T) { + content := `apiVersion: cloudcredential.openshift.io/v1 +kind: CredentialsRequest +metadata: + name: cloud-credentials + namespace: openshift-ingress-operator +spec: + secretRef: + name: cloud-credentials + namespace: openshift-ingress-operator + serviceAccountNames: + - ingress-operator + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: AWSProviderSpec + statementEntries: + - effect: Allow + action: + - elasticloadbalancing:DescribeLoadBalancers + resource: "*" +` + tmpDir := t.TempDir() + filename := "99_credentials-request_openshift-ingress-operator-cloud-credentials.yaml" + if err := os.WriteFile(filepath.Join(tmpDir, filename), []byte(content), 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, filename)) + if err != nil { + t.Fatalf("failed to read test file: %v", err) + } + + req, err := parseCredentialRequestBytes(data, filename) + assert.NoError(t, err) + assert.NotNil(t, req) + assert.IsType(t, &AWSProviderSpec{}, req.ProviderSpec) +} From f166be83204eaad49df48e8e7b0e3789a9ecf722 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 1 Jul 2026 16:39:34 -0700 Subject: [PATCH 4/8] aws: provision OIDC S3 bucket, IAM OIDC provider, and STS IAM roles Implement the core STS infrastructure provisioning during CAPI PreProvision: create the OIDC S3 bucket with discovery documents and JWKS, create the IAM OIDC identity provider, and create per-component IAM roles with trust and permission policies. The JWKS key ID uses PKIX DER SHA-256 hash matching kube-apiserver. IAM policy generation uses JSON templates instead of struct marshaling. Shared helpers GetS3DNSSuffix and OIDCIssuerURL are added to endpoints.go to avoid partition-specific hostname duplication. Co-Authored-By: Claude Opus 4.6 --- pkg/asset/cluster/cluster.go | 3 + pkg/asset/installconfig/aws/endpoints.go | 36 ++ pkg/infrastructure/aws/clusterapi/aws.go | 8 + pkg/infrastructure/aws/clusterapi/sts.go | 462 ++++++++++++++++++++ pkg/infrastructure/clusterapi/clusterapi.go | 19 +- pkg/infrastructure/clusterapi/types.go | 15 +- 6 files changed, 531 insertions(+), 12 deletions(-) create mode 100644 pkg/infrastructure/aws/clusterapi/sts.go diff --git a/pkg/asset/cluster/cluster.go b/pkg/asset/cluster/cluster.go index 02c13b5974f..65a3ad30c78 100644 --- a/pkg/asset/cluster/cluster.go +++ b/pkg/asset/cluster/cluster.go @@ -15,6 +15,7 @@ import ( "github.com/openshift/installer/pkg/asset/cluster/azure" "github.com/openshift/installer/pkg/asset/cluster/openstack" "github.com/openshift/installer/pkg/asset/cluster/tfvars" + "github.com/openshift/installer/pkg/asset/credentialsrequest" "github.com/openshift/installer/pkg/asset/ignition/bootstrap" "github.com/openshift/installer/pkg/asset/ignition/machine" "github.com/openshift/installer/pkg/asset/installconfig" @@ -80,6 +81,8 @@ func (c *Cluster) Dependencies() []asset.Asset { new(rhcos.Image), &manifests.Manifests{}, &tls.RootCA{}, + &tls.BoundSASigningKey{}, + &credentialsrequest.CredentialsRequests{}, } } diff --git a/pkg/asset/installconfig/aws/endpoints.go b/pkg/asset/installconfig/aws/endpoints.go index 700d0bee6ab..748bd751684 100644 --- a/pkg/asset/installconfig/aws/endpoints.go +++ b/pkg/asset/installconfig/aws/endpoints.go @@ -3,6 +3,8 @@ package aws import ( "context" "fmt" + "net/url" + "strings" "sync" "github.com/aws/aws-sdk-go-v2/aws" @@ -364,6 +366,40 @@ func GetDefaultServiceEndpoint(ctx context.Context, service string, opts Endpoin return endpoint, nil } +// GetS3DNSSuffix resolves the DNS suffix for S3 in the given region. +// For example, us-east-1 returns "amazonaws.com" and cn-north-1 returns "amazonaws.com.cn". +func GetS3DNSSuffix(region string) (string, error) { + resolver := s3.NewDefaultEndpointResolverV2() + endpoint, err := resolver.ResolveEndpoint(context.Background(), s3.EndpointParameters{ + Region: ®ion, + }) + if err != nil { + return "", err + } + + endpointURL, err := url.Parse(endpoint.URI.String()) + if err != nil { + return "", fmt.Errorf("failed to parse endpoint URL %s: %w", endpoint.URI.String(), err) + } + + hostParts := strings.Split(endpointURL.Hostname(), ".") + if len(hostParts) < 3 { + return "", fmt.Errorf("invalid hostname: %s", endpointURL.Hostname()) + } + + return strings.Join(hostParts[2:], "."), nil +} + +// OIDCIssuerURL constructs the OIDC issuer URL for the given cluster +// infrastructure ID and region. +func OIDCIssuerURL(infraID, region string) (string, error) { + dnsSuffix, err := GetS3DNSSuffix(region) + if err != nil { + return "", err + } + return fmt.Sprintf("https://%s.s3.%s.%s", typesaws.OIDCBucketName(infraID), region, dnsSuffix), nil +} + // GetPartitionIDForRegion retrieves the partition ID for a given region. // For example, us-east-1 returns "aws" and "eusc-de-east-1" returns "aws-eusc". func GetPartitionIDForRegion(ctx context.Context, region string) (string, error) { diff --git a/pkg/infrastructure/aws/clusterapi/aws.go b/pkg/infrastructure/aws/clusterapi/aws.go index 79e1ba51cb3..2060c92d583 100644 --- a/pkg/infrastructure/aws/clusterapi/aws.go +++ b/pkg/infrastructure/aws/clusterapi/aws.go @@ -55,11 +55,19 @@ func (*Provider) Name() string { return awstypes.Name } func (*Provider) PublicGatherEndpoint() clusterapi.GatherEndpoint { return clusterapi.ExternalIP } // PreProvision creates the IAM roles used by all nodes in the cluster. +// When STS managed mode is active, it also creates the OIDC S3 bucket, +// IAM OIDC provider, and per-component IAM roles. func (*Provider) PreProvision(ctx context.Context, in clusterapi.PreProvisionInput) error { if err := createIAMRoles(ctx, in.InfraID, in.InstallConfig); err != nil { return fmt.Errorf("failed to create IAM roles: %w", err) } + if in.InstallConfig.Config.Platform.AWS.IsSTSManaged() { + if err := provisionOIDCAndIAMRoles(ctx, in); err != nil { + return fmt.Errorf("failed to provision OIDC and IAM roles: %w", err) + } + } + // The AWSMachine manifests might already have the AMI ID set from the machine pool which takes into account the // ways in which the AMI can be specified: the default rhcos if already in the target region, a custom AMI ID set in // platform.aws.amiID, and a custom AMI ID specified in the controlPlane stanza. So we just get the value from the diff --git a/pkg/infrastructure/aws/clusterapi/sts.go b/pkg/infrastructure/aws/clusterapi/sts.go new file mode 100644 index 00000000000..f48f3444731 --- /dev/null +++ b/pkg/infrastructure/aws/clusterapi/sts.go @@ -0,0 +1,462 @@ +package clusterapi + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha1" //nolint:gosec + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + jose "github.com/go-jose/go-jose/v4" + "github.com/sirupsen/logrus" + + ccov1 "github.com/openshift/cloud-credential-operator/pkg/apis/cloudcredential/v1" + "github.com/openshift/installer/pkg/asset/credentialsrequest" + awsconfig "github.com/openshift/installer/pkg/asset/installconfig/aws" + tlsconfig "github.com/openshift/installer/pkg/asset/tls" + "github.com/openshift/installer/pkg/infrastructure/clusterapi" + awstypes "github.com/openshift/installer/pkg/types/aws" +) + +const ( + oidcClientID = "openshift" + stsAudience = "sts.amazonaws.com" +) + +// provisionOIDCAndIAMRoles creates the OIDC infrastructure and IAM roles needed for +// STS-based credential provisioning. It creates the S3 bucket, OIDC provider, and IAM roles. +func provisionOIDCAndIAMRoles(ctx context.Context, in clusterapi.PreProvisionInput) error { + infraID := in.InfraID + p := in.InstallConfig.Config.Platform.AWS + region := p.Region + endpointOpts := awsconfig.EndpointOptions{ + Region: region, + Endpoints: p.ServiceEndpoints, + } + + pubKeyData := in.BoundSASigningKey.PublicKeyData() + if pubKeyData == nil { + return fmt.Errorf("SA signing public key not available for STS provisioning") + } + + saSigningPubKey, err := tlsconfig.PemToPublicKey(pubKeyData) + if err != nil { + return fmt.Errorf("failed to parse SA signing public key: %w", err) + } + + partitionID, err := awsconfig.GetPartitionIDForRegion(ctx, region) + if err != nil { + return fmt.Errorf("failed to get partition for region %s: %w", region, err) + } + + iamTags := buildIAMTags(infraID, p.UserTags) + s3Tags := buildS3Tags(infraID, p.UserTags) + + iamClient, err := awsconfig.NewIAMClient(ctx, endpointOpts) + if err != nil { + return fmt.Errorf("failed to create IAM client: %w", err) + } + + s3Client, err := awsconfig.NewS3Client(ctx, endpointOpts) + if err != nil { + return fmt.Errorf("failed to create S3 client: %w", err) + } + + issuerURL, err := createOIDCBucket(ctx, s3Client, infraID, region, partitionID, saSigningPubKey, s3Tags) + if err != nil { + return fmt.Errorf("failed to create OIDC S3 bucket: %w", err) + } + logrus.Infof("Created OIDC S3 bucket: %s", issuerURL) + + oidcProviderARN, err := createOIDCProvider(ctx, iamClient, issuerURL, iamTags) + if err != nil { + return fmt.Errorf("failed to create IAM OIDC provider: %w", err) + } + logrus.Infof("Created IAM OIDC provider: %s", oidcProviderARN) + + if err := createSTSIAMRoles(ctx, iamClient, infraID, oidcProviderARN, issuerURL, in.CredentialsRequests.Requests, iamTags); err != nil { + return fmt.Errorf("failed to create STS IAM roles: %w", err) + } + + return nil +} + +func buildIAMTags(infraID string, userTags map[string]string) []iamtypes.Tag { + tags := []iamtypes.Tag{ + { + Key: aws.String(fmt.Sprintf("kubernetes.io/cluster/%s", infraID)), + Value: aws.String("owned"), + }, + } + for k, v := range userTags { + tags = append(tags, iamtypes.Tag{ + Key: aws.String(k), + Value: aws.String(v), + }) + } + return tags +} + +func buildS3Tags(infraID string, userTags map[string]string) []s3types.Tag { + tags := []s3types.Tag{ + { + Key: aws.String(fmt.Sprintf("kubernetes.io/cluster/%s", infraID)), + Value: aws.String("owned"), + }, + } + for k, v := range userTags { + tags = append(tags, s3types.Tag{ + Key: aws.String(k), + Value: aws.String(v), + }) + } + return tags +} + +// createOIDCBucket creates the S3 bucket that serves the OIDC discovery +// document and JWKS for the cluster's service account token signing key. +func createOIDCBucket(ctx context.Context, client *s3.Client, infraID, region, partitionID string, pubKey *rsa.PublicKey, tags []s3types.Tag) (string, error) { + bucketName := awstypes.OIDCBucketName(infraID) + + createInput := &s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + } + if region != awstypes.UsEast1RegionID { + createInput.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{ + LocationConstraint: s3types.BucketLocationConstraint(region), + } + } + + if _, err := client.CreateBucket(ctx, createInput); err != nil { + var boe *s3types.BucketAlreadyOwnedByYou + if !errors.As(err, &boe) { + return "", fmt.Errorf("failed to create bucket %s: %w", bucketName, err) + } + logrus.Debugf("Reusing existing OIDC bucket %s", bucketName) + } + + if _, err := client.PutBucketTagging(ctx, &s3.PutBucketTaggingInput{ + Bucket: aws.String(bucketName), + Tagging: &s3types.Tagging{TagSet: tags}, + }); err != nil { + return "", fmt.Errorf("failed to tag bucket: %w", err) + } + + if _, err := client.PutPublicAccessBlock(ctx, &s3.PutPublicAccessBlockInput{ + Bucket: aws.String(bucketName), + PublicAccessBlockConfiguration: &s3types.PublicAccessBlockConfiguration{ + BlockPublicAcls: aws.Bool(true), + BlockPublicPolicy: aws.Bool(false), + IgnorePublicAcls: aws.Bool(true), + RestrictPublicBuckets: aws.Bool(false), + }, + }); err != nil { + return "", fmt.Errorf("failed to set public access block: %w", err) + } + + bucketARN := fmt.Sprintf("arn:%s:s3:::%s", partitionID, bucketName) + policyDoc := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "%s/*" + } + ] +}`, bucketARN) + if _, err := client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: aws.String(bucketName), + Policy: aws.String(policyDoc), + }); err != nil { + return "", fmt.Errorf("failed to set bucket policy: %w", err) + } + + issuerURL, err := awsconfig.OIDCIssuerURL(infraID, region) + if err != nil { + return "", err + } + + jwksBytes, keyID, err := BuildJSONWebKeySet(pubKey) + if err != nil { + return "", fmt.Errorf("failed to build JWKS: %w", err) + } + + discoveryDoc := buildDiscoveryDocument(issuerURL) + + if _, err := client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(".well-known/openid-configuration"), + Body: strings.NewReader(discoveryDoc), + ContentType: aws.String("application/json"), + }); err != nil { + return "", fmt.Errorf("failed to upload discovery document: %w", err) + } + + if _, err := client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("keys.json"), + Body: strings.NewReader(string(jwksBytes)), + ContentType: aws.String("application/json"), + }); err != nil { + return "", fmt.Errorf("failed to upload JWKS: %w", err) + } + + logrus.Debugf("Uploaded OIDC discovery document and JWKS (key-id=%s) to bucket %s", keyID, bucketName) + return issuerURL, nil +} + +func buildDiscoveryDocument(issuerURL string) string { + return fmt.Sprintf(`{ + "issuer": "%s", + "jwks_uri": "%s/keys.json", + "response_types_supported": ["id_token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "claims_supported": ["aud", "exp", "sub", "iat", "iss"] +}`, issuerURL, issuerURL) +} + +// BuildJSONWebKeySet builds a JWKS document for the given public key. +// The key ID is derived by hashing the PKIX DER encoding of the public key with SHA-256, matching the method used by kube-apiserver. +// Reference: https://github.com/kubernetes/kubernetes/blob/0f140bf1eeaf63c155f5eba1db8db9b5d52d5467/pkg/serviceaccount/jwt.go#L89-L111. +func BuildJSONWebKeySet(pubKey *rsa.PublicKey) ([]byte, string, error) { + derBytes, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + return nil, "", fmt.Errorf("failed to serialize public key to DER format: %w", err) + } + + hasher := crypto.SHA256.New() + hasher.Write(derBytes) //nolint:errcheck + keyID := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) + + jwk := jose.JSONWebKey{ + Key: pubKey, + KeyID: keyID, + Algorithm: string(jose.RS256), + Use: "sig", + } + + jwks := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}} + data, err := json.Marshal(jwks) + if err != nil { + return nil, "", fmt.Errorf("failed to marshal JWKS: %w", err) + } + return data, keyID, nil +} + +// createOIDCProvider creates an IAM OpenID Connect provider that trusts +// the given issuer URL for ServiceAccount token authentication. +func createOIDCProvider(ctx context.Context, client *iam.Client, issuerURL string, tags []iamtypes.Tag) (string, error) { + thumbprint, err := getIssuerTLSThumbprint(ctx, issuerURL) + if err != nil { + return "", fmt.Errorf("failed to get TLS thumbprint for %s: %w", issuerURL, err) + } + + result, err := client.CreateOpenIDConnectProvider(ctx, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(issuerURL), + ClientIDList: []string{oidcClientID, stsAudience}, + ThumbprintList: []string{thumbprint}, + Tags: tags, + }) + if err != nil { + return "", fmt.Errorf("failed to create OIDC provider: %w", err) + } + + return *result.OpenIDConnectProviderArn, nil +} + +// getIssuerTLSThumbprint retrieves the SHA-1 fingerprint of the root CA +// certificate for the given issuer URL's TLS chain. +func getIssuerTLSThumbprint(ctx context.Context, issuerURL string) (string, error) { + u, err := url.Parse(issuerURL) + if err != nil { + return "", fmt.Errorf("failed to parse issuer URL: %w", err) + } + + host := u.Host + if !strings.Contains(host, ":") { + host += ":443" + } + + dialer := &tls.Dialer{Config: &tls.Config{ + MinVersion: tls.VersionTLS12, + }} + conn, err := dialer.DialContext(ctx, "tcp", host) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", host, err) + } + defer conn.Close() + + tlsConn, ok := conn.(*tls.Conn) + if !ok { + return "", fmt.Errorf("connection to %s is not TLS", host) + } + certs := tlsConn.ConnectionState().PeerCertificates + if len(certs) == 0 { + return "", fmt.Errorf("no certificates found for %s", host) + } + + rootCert := certs[len(certs)-1] + fingerprint := sha1.Sum(rootCert.Raw) //nolint:gosec + return fmt.Sprintf("%x", fingerprint), nil +} + +func extractIssuerHost(issuerURL string) (string, error) { + u, err := url.Parse(issuerURL) + if err != nil { + return "", fmt.Errorf("failed to parse issuer URL %s: %w", issuerURL, err) + } + return u.Host + u.Path, nil +} + +// createSTSIAMRoles creates per-component IAM roles with trust policies +// that allow assuming the role via OIDC web identity tokens. +func createSTSIAMRoles(ctx context.Context, client *iam.Client, infraID, oidcProviderARN, issuerURL string, credReqs []credentialsrequest.CredentialRequest, tags []iamtypes.Tag) error { + issuerHost, err := extractIssuerHost(issuerURL) + if err != nil { + return err + } + + for _, cr := range credReqs { + roleName := awstypes.STSRoleName(infraID, cr.SecretRefNamespace, cr.SecretRefName) + logrus.Infof("Creating STS IAM role %s for %s/%s", roleName, cr.SecretRefNamespace, cr.SecretRefName) + + trustPolicy, err := buildTrustPolicy(oidcProviderARN, issuerHost, cr.SecretRefNamespace, cr.ServiceAccountNames) + if err != nil { + return fmt.Errorf("failed to build trust policy for role %s: %w", roleName, err) + } + + if err := createOrUpdateRole(ctx, client, roleName, trustPolicy, tags); err != nil { + return fmt.Errorf("failed to create role %s: %w", roleName, err) + } + + awsSpec, ok := cr.ProviderSpec.(*credentialsrequest.AWSProviderSpec) + if !ok { + return fmt.Errorf("credential request %s/%s does not have an AWS provider spec", cr.SecretRefNamespace, cr.SecretRefName) + } + + permPolicy, err := buildPermissionPolicy(awsSpec.StatementEntries) + if err != nil { + return fmt.Errorf("failed to build permission policy for role %s: %w", roleName, err) + } + + policyName := roleName + "-policy" + if _, err := client.PutRolePolicy(ctx, &iam.PutRolePolicyInput{ + RoleName: aws.String(roleName), + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(permPolicy), + }); err != nil { + return fmt.Errorf("failed to put inline policy on role %s: %w", roleName, err) + } + } + + logrus.Infof("Created %d STS IAM roles", len(credReqs)) + return nil +} + +func createOrUpdateRole(ctx context.Context, client *iam.Client, roleName, trustPolicy string, tags []iamtypes.Tag) error { + _, err := client.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)}) + if err != nil { + var noSuchEntity *iamtypes.NoSuchEntityException + if !errors.As(err, &noSuchEntity) { + return fmt.Errorf("failed to check for existing role: %w", err) + } + + if _, err := client.CreateRole(ctx, &iam.CreateRoleInput{ + RoleName: aws.String(roleName), + AssumeRolePolicyDocument: aws.String(trustPolicy), + Tags: tags, + }); err != nil { + return fmt.Errorf("failed to create role: %w", err) + } + return nil + } + + if _, err := client.UpdateAssumeRolePolicy(ctx, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String(roleName), + PolicyDocument: aws.String(trustPolicy), + }); err != nil { + return fmt.Errorf("failed to update trust policy on existing role: %w", err) + } + return nil +} + +const roleTrustPolicyTemplate = `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "%s" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": %s + } + ] +}` + +const rolePermissionPolicyTemplate = `{ + "Version": "2012-10-17", + "Statement": %s +}` + +func buildTrustPolicy(oidcProviderARN, issuerHost, namespace string, serviceAccountNames []string) (string, error) { + subjects := make([]string, 0, len(serviceAccountNames)) + for _, sa := range serviceAccountNames { + subjects = append(subjects, fmt.Sprintf("system:serviceaccount:%s:%s", namespace, sa)) + } + + condition, err := json.Marshal(map[string]interface{}{ + "StringEquals": map[string]interface{}{ + issuerHost + ":sub": subjects, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to marshal trust policy condition: %w", err) + } + + return fmt.Sprintf(roleTrustPolicyTemplate, oidcProviderARN, string(condition)), nil +} + +func buildPermissionPolicy(entries []ccov1.StatementEntry) (string, error) { + type statement struct { + Effect string `json:"Effect"` + Action []string `json:"Action"` + Resource string `json:"Resource"` + Condition interface{} `json:"Condition,omitempty"` + } + + stmts := make([]statement, 0, len(entries)) + for _, e := range entries { + s := statement{ + Effect: e.Effect, + Action: e.Action, + Resource: e.Resource, + } + if len(e.PolicyCondition) > 0 { + s.Condition = e.PolicyCondition + } + stmts = append(stmts, s) + } + + stmtsJSON, err := json.Marshal(stmts) + if err != nil { + return "", fmt.Errorf("failed to marshal permission policy statements: %w", err) + } + + return fmt.Sprintf(rolePermissionPolicyTemplate, string(stmtsJSON)), nil +} diff --git a/pkg/infrastructure/clusterapi/clusterapi.go b/pkg/infrastructure/clusterapi/clusterapi.go index 8676de9975d..8f4104b16fd 100644 --- a/pkg/infrastructure/clusterapi/clusterapi.go +++ b/pkg/infrastructure/clusterapi/clusterapi.go @@ -26,6 +26,7 @@ import ( "github.com/openshift/installer/pkg/asset" "github.com/openshift/installer/pkg/asset/cluster/metadata" "github.com/openshift/installer/pkg/asset/cluster/tfvars" + "github.com/openshift/installer/pkg/asset/credentialsrequest" "github.com/openshift/installer/pkg/asset/ignition/bootstrap" "github.com/openshift/installer/pkg/asset/ignition/machine" "github.com/openshift/installer/pkg/asset/installconfig" @@ -88,6 +89,8 @@ func (i *InfraProvider) Provision(ctx context.Context, dir string, parents asset workerIgnAsset := &machine.Worker{} tfvarsAsset := &tfvars.TerraformVariables{} rootCA := &tls.RootCA{} + boundSASigningKey := &tls.BoundSASigningKey{} + credReqs := &credentialsrequest.CredentialsRequests{} parents.Get( manifestsAsset, workersAsset, @@ -102,6 +105,8 @@ func (i *InfraProvider) Provision(ctx context.Context, dir string, parents asset capiMachinesAsset, tfvarsAsset, rootCA, + boundSASigningKey, + credReqs, ) var capiClusters []*clusterv1.Cluster @@ -127,12 +132,14 @@ func (i *InfraProvider) Provision(ctx context.Context, dir string, parents asset if p, ok := i.impl.(PreProvider); ok { preProvisionInput := PreProvisionInput{ - InfraID: clusterID.InfraID, - InstallConfig: installConfig, - RhcosImage: rhcosImage, - ManifestsAsset: manifestsAsset, - MachineManifests: machineManifests, - WorkersAsset: workersAsset, + InfraID: clusterID.InfraID, + InstallConfig: installConfig, + RhcosImage: rhcosImage, + ManifestsAsset: manifestsAsset, + MachineManifests: machineManifests, + WorkersAsset: workersAsset, + BoundSASigningKey: boundSASigningKey, + CredentialsRequests: credReqs, } timer.StartTimer(preProvisionStage) if err := p.PreProvision(ctx, preProvisionInput); err != nil { diff --git a/pkg/infrastructure/clusterapi/types.go b/pkg/infrastructure/clusterapi/types.go index e3647eccb82..87126481465 100644 --- a/pkg/infrastructure/clusterapi/types.go +++ b/pkg/infrastructure/clusterapi/types.go @@ -8,6 +8,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/openshift/installer/pkg/asset/cluster/tfvars" + "github.com/openshift/installer/pkg/asset/credentialsrequest" "github.com/openshift/installer/pkg/asset/installconfig" "github.com/openshift/installer/pkg/asset/machines" "github.com/openshift/installer/pkg/asset/manifests" @@ -43,12 +44,14 @@ type PreProvider interface { // PreProvisionInput collects the args passed to the PreProvision call. type PreProvisionInput struct { - InfraID string - InstallConfig *installconfig.InstallConfig - RhcosImage *rhcos.Image - ManifestsAsset *manifests.Manifests - MachineManifests []client.Object - WorkersAsset *machines.Worker + InfraID string + InstallConfig *installconfig.InstallConfig + RhcosImage *rhcos.Image + ManifestsAsset *manifests.Manifests + MachineManifests []client.Object + WorkersAsset *machines.Worker + BoundSASigningKey *tls.BoundSASigningKey + CredentialsRequests *credentialsrequest.CredentialsRequests } // IgnitionProvider handles preconditions for bootstrap ignition, From f7dc72dcef311bbf67c9772240956c66be927e0f Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 1 Jul 2026 16:39:46 -0700 Subject: [PATCH 5/8] aws: generate STS credential secrets and Authentication CR manifests Add Authentication CR manifest that sets the cluster's service account issuer URL to the S3-hosted OIDC endpoint. Add per-component credential secret manifests with predicted IAM role ARNs. Shared OIDCBucketName and STSRoleName functions in pkg/types/aws/sts.go ensure naming consistency between infrastructure provisioning and manifest generation. Co-Authored-By: Claude Opus 4.6 --- pkg/asset/manifests/authentication.go | 88 +++++++++++++++++ pkg/asset/manifests/credentialsecrets.go | 118 +++++++++++++++++++++++ pkg/asset/manifests/openshift.go | 10 +- pkg/types/aws/sts.go | 22 +++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 pkg/asset/manifests/authentication.go create mode 100644 pkg/asset/manifests/credentialsecrets.go create mode 100644 pkg/types/aws/sts.go diff --git a/pkg/asset/manifests/authentication.go b/pkg/asset/manifests/authentication.go new file mode 100644 index 00000000000..1b2b7598cc2 --- /dev/null +++ b/pkg/asset/manifests/authentication.go @@ -0,0 +1,88 @@ +package manifests + +import ( + "context" + "fmt" + "path" + + "github.com/sirupsen/logrus" + + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/installconfig" + awsconfig "github.com/openshift/installer/pkg/asset/installconfig/aws" + awstypes "github.com/openshift/installer/pkg/types/aws" +) + +var ( + clusterAuthenticationFilename = path.Join(openshiftManifestDir, "cluster-authentication-02-config.yaml") +) + +// Authentication generates the Authentication CR that configures +// the cluster's service account issuer URL. +type Authentication struct { + FileList []*asset.File +} + +var _ asset.WritableAsset = (*Authentication)(nil) + +// Name returns a human-friendly name for the asset. +func (*Authentication) Name() string { + return "Authentication CR" +} + +// Dependencies returns all of the dependencies directly needed to generate +// the asset. +func (*Authentication) Dependencies() []asset.Asset { + return []asset.Asset{ + &installconfig.InstallConfig{}, + &installconfig.ClusterID{}, + } +} + +// Generate generates the Authentication CR manifest. +func (a *Authentication) Generate(_ context.Context, dependencies asset.Parents) error { + installConfig := &installconfig.InstallConfig{} + clusterID := &installconfig.ClusterID{} + dependencies.Get(installConfig, clusterID) + + platform := installConfig.Config.Platform + switch platform.Name() { + case awstypes.Name: + if !installConfig.Config.Platform.AWS.IsSTSManaged() { + return nil + } + + issuerURL, err := awsconfig.OIDCIssuerURL(clusterID.InfraID, platform.AWS.Region) + if err != nil { + return fmt.Errorf("failed to construct OIDC issuer URL: %w", err) + } + manifest := fmt.Sprintf(`apiVersion: config.openshift.io/v1 +kind: Authentication +metadata: + name: cluster +spec: + serviceAccountIssuer: %s +`, issuerURL) + + a.FileList = []*asset.File{ + { + Filename: clusterAuthenticationFilename, + Data: []byte(manifest), + }, + } + default: + logrus.Debugf("Skipped generating Authentication CR for unsupported platform %s", installConfig.Config.Platform.Name()) + } + + return nil +} + +// Files returns the files generated by the asset. +func (a *Authentication) Files() []*asset.File { + return a.FileList +} + +// Load loads the already-rendered files back from disk. +func (a *Authentication) Load(asset.FileFetcher) (bool, error) { + return false, nil +} diff --git a/pkg/asset/manifests/credentialsecrets.go b/pkg/asset/manifests/credentialsecrets.go new file mode 100644 index 00000000000..2d80a8d31cc --- /dev/null +++ b/pkg/asset/manifests/credentialsecrets.go @@ -0,0 +1,118 @@ +package manifests + +import ( + "context" + "fmt" + "path" + + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/sirupsen/logrus" + + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/credentialsrequest" + "github.com/openshift/installer/pkg/asset/installconfig" + installconfigaws "github.com/openshift/installer/pkg/asset/installconfig/aws" + awstypes "github.com/openshift/installer/pkg/types/aws" +) + +const cloudCredsSecretFilenameFmt = "99_cloud-creds-secret-%s-%s.yaml" //nolint:gosec + +// CredentialSecrets generates per-component Secret manifests. +type CredentialSecrets struct { + FileList []*asset.File +} + +var _ asset.WritableAsset = (*CredentialSecrets)(nil) + +// Name returns a human-friendly name for the asset. +func (*CredentialSecrets) Name() string { + return "Credential Secrets" +} + +// Dependencies returns all of the dependencies directly needed to generate +// the asset. +func (*CredentialSecrets) Dependencies() []asset.Asset { + return []asset.Asset{ + &installconfig.InstallConfig{}, + &installconfig.ClusterID{}, + &credentialsrequest.CredentialsRequests{}, + } +} + +// Generate generates the credential secret manifests. +func (s *CredentialSecrets) Generate(ctx context.Context, dependencies asset.Parents) error { + installConfig := &installconfig.InstallConfig{} + clusterID := &installconfig.ClusterID{} + credReqs := &credentialsrequest.CredentialsRequests{} + dependencies.Get(installConfig, clusterID, credReqs) + + platform := installConfig.Config.Platform + switch platform.Name() { + case awstypes.Name: + if !installConfig.Config.Platform.AWS.IsSTSManaged() { + return nil + } + + region := platform.AWS.Region + infraID := clusterID.InfraID + + endpointOpts := installconfigaws.EndpointOptions{ + Region: region, + Endpoints: platform.AWS.ServiceEndpoints, + } + + partitionID, err := installconfigaws.GetPartitionIDForRegion(ctx, region) + if err != nil { + return fmt.Errorf("failed to get partition for region %s: %w", region, err) + } + + stsClient, err := installconfigaws.NewSTSClient(ctx, endpointOpts) + if err != nil { + return fmt.Errorf("failed to create STS client: %w", err) + } + + identity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return fmt.Errorf("failed to get AWS account ID: %w", err) + } + accountID := *identity.Account + + for _, cr := range credReqs.Requests { + roleName := awstypes.STSRoleName(infraID, cr.SecretRefNamespace, cr.SecretRefName) + roleARN := fmt.Sprintf("arn:%s:iam::%s:role/%s", partitionID, accountID, roleName) + + secretManifest := fmt.Sprintf(`apiVersion: v1 +kind: Secret +metadata: + name: %s + namespace: %s +stringData: + credentials: | + [default] + sts_regional_endpoints = regional + role_arn = %s + web_identity_token_file = /var/run/secrets/openshift/serviceaccount/token +`, cr.SecretRefName, cr.SecretRefNamespace, roleARN) + + filename := fmt.Sprintf(cloudCredsSecretFilenameFmt, cr.SecretRefNamespace, cr.SecretRefName) + s.FileList = append(s.FileList, &asset.File{ + Filename: path.Join(openshiftManifestDir, filename), + Data: []byte(secretManifest), + }) + } + default: + logrus.Debugf("Skipped generating credential secrets for unsupported platform %s", platform.Name()) + } + + return nil +} + +// Files returns the files generated by the asset. +func (s *CredentialSecrets) Files() []*asset.File { + return s.FileList +} + +// Load loads the already-rendered files back from disk. +func (s *CredentialSecrets) Load(asset.FileFetcher) (bool, error) { + return false, nil +} diff --git a/pkg/asset/manifests/openshift.go b/pkg/asset/manifests/openshift.go index 227c5fca101..076b99a6c4e 100644 --- a/pkg/asset/manifests/openshift.go +++ b/pkg/asset/manifests/openshift.go @@ -77,6 +77,8 @@ func (o *Openshift) Dependencies() []asset.Asset { new(rhcos.Image), &openshift.AzureCloudProviderSecret{}, &OSImageStream{}, + &CredentialSecrets{}, + &Authentication{}, } } @@ -262,6 +264,8 @@ func (o *Openshift) Generate(ctx context.Context, dependencies asset.Parents) er baremetalConfig := &openshift.BaremetalConfig{} rhcosImage := new(rhcos.Image) osImageStream := &OSImageStream{} + stsCredSecrets := &CredentialSecrets{} + stsAuthCR := &Authentication{} dependencies.Get( cloudCredsSecret, @@ -269,7 +273,9 @@ func (o *Openshift) Generate(ctx context.Context, dependencies asset.Parents) er roleCloudCredsSecretReader, baremetalConfig, rhcosImage, - osImageStream) + osImageStream, + stsCredSecrets, + stsAuthCR) assetData := map[string][]byte{ "99_kubeadmin-password-secret.yaml": applyTemplateData(kubeadminPasswordSecret.Files()[0].Data, templateData), @@ -303,6 +309,8 @@ func (o *Openshift) Generate(ctx context.Context, dependencies asset.Parents) er o.FileList = append(o.FileList, openshiftInstall.Files()...) o.FileList = append(o.FileList, featureGate.Files()...) o.FileList = append(o.FileList, osImageStream.Files()...) + o.FileList = append(o.FileList, stsCredSecrets.Files()...) + o.FileList = append(o.FileList, stsAuthCR.Files()...) asset.SortFiles(o.FileList) diff --git a/pkg/types/aws/sts.go b/pkg/types/aws/sts.go new file mode 100644 index 00000000000..863bf1de630 --- /dev/null +++ b/pkg/types/aws/sts.go @@ -0,0 +1,22 @@ +package aws + +import "fmt" + +const stsMaxRoleNameLen = 64 + +// OIDCBucketName returns the S3 bucket name used to host the OIDC +// discovery documents for the given cluster infrastructure ID. +func OIDCBucketName(infraID string) string { + return fmt.Sprintf("%s-oidc", infraID) +} + +// STSRoleName computes the deterministic IAM role name for a given +// credentials request, used by both infrastructure provisioning and +// manifest generation to predict role ARNs. +func STSRoleName(infraID, namespace, name string) string { + roleName := fmt.Sprintf("%s-%s-%s", infraID, namespace, name) + if len(roleName) > stsMaxRoleNameLen { + roleName = roleName[:stsMaxRoleNameLen] + } + return roleName +} From 0660432d65c23c9cf03f15b3056401bf8753b50b Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 1 Jul 2026 16:39:59 -0700 Subject: [PATCH 6/8] aws: add STS permission groups for credential simulation Add PermissionCreateSTS and PermissionDeleteSTS permission groups covering S3, IAM, and STS API calls needed for OIDC provisioning and cleanup. Co-Authored-By: Claude Opus 4.6 --- pkg/asset/installconfig/aws/permissions.go | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pkg/asset/installconfig/aws/permissions.go b/pkg/asset/installconfig/aws/permissions.go index 560b1daf415..3445a4f1654 100644 --- a/pkg/asset/installconfig/aws/permissions.go +++ b/pkg/asset/installconfig/aws/permissions.go @@ -97,6 +97,14 @@ const ( // PermissionDynamicHostAllocation is a permission set required when cleaning up dynamic hosts that were allocated during the life of the cluster. PermissionDynamicHostAllocation PermissionGroup = "permission-dynamic-host-allocation" + + // PermissionCreateSTS is a permission set required for fully managed STS provisioning + // (S3 OIDC bucket, IAM OIDC provider, and IAM roles). + PermissionCreateSTS PermissionGroup = "create-sts" + + // PermissionDeleteSTS is a permission set required for destroying STS resources + // (OIDC provider, S3 bucket, and IAM roles). + PermissionDeleteSTS PermissionGroup = "delete-sts" ) var permissions = map[PermissionGroup][]string{ @@ -458,6 +466,34 @@ var permissions = map[PermissionGroup][]string{ // This is only used during cluster destroy if during cluster destroy we detect a dedicated host with appropriate tags on it. "ec2:ReleaseHosts", }, + PermissionCreateSTS: { + "iam:CreateOpenIDConnectProvider", + "iam:CreateRole", + "iam:DeleteRole", + "iam:GetOpenIDConnectProvider", + "iam:GetRole", + "iam:ListOpenIDConnectProviders", + "iam:PutRolePolicy", + "iam:TagOpenIDConnectProvider", + "iam:TagRole", + "s3:CreateBucket", + "s3:GetBucketTagging", + "s3:PutBucketPolicy", + "s3:PutBucketTagging", + "s3:PutObject", + "s3:PutPublicAccessBlock", + "sts:GetCallerIdentity", + }, + PermissionDeleteSTS: { + "iam:DeleteOpenIDConnectProvider", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:ListOpenIDConnectProviders", + "iam:ListRolePolicies", + "s3:DeleteBucket", + "s3:DeleteObject", + "s3:ListBucket", + }, } // ValidateCreds will try to create an AWS session, and also verify that the current credentials @@ -604,6 +640,10 @@ func RequiredPermissionGroups(ic *types.InstallConfig) []PermissionGroup { permissionGroups = append(permissionGroups, PermissionDedicatedHosts) } + if ic.AWS.IsSTSManaged() { + permissionGroups = append(permissionGroups, PermissionCreateSTS, PermissionDeleteSTS) + } + return permissionGroups } From d886843bee3c7ccf775c5e753275397311e8bec7 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 1 Jul 2026 16:40:10 -0700 Subject: [PATCH 7/8] aws: clean up OIDC provider during cluster destroy Add OIDC provider and STS IAM role cleanup to the AWS destroy path. OIDC providers are discovered by matching the issuer URL against the infrastructure ID. IAM roles are discovered by tag-based search. Co-Authored-By: Claude Opus 4.6 --- pkg/destroy/aws/aws.go | 40 +++++++++++++++++++++++++++++++++++ pkg/destroy/aws/iamhelpers.go | 18 ++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pkg/destroy/aws/aws.go b/pkg/destroy/aws/aws.go index 97d1fbc04c3..592ddb1e4b5 100644 --- a/pkg/destroy/aws/aws.go +++ b/pkg/destroy/aws/aws.go @@ -434,9 +434,49 @@ func (o *ClusterUninstaller) findUntaggableResources(ctx context.Context, delete resources.Insert(arnString) } } + + oidcProviderARN, err := o.findOIDCProvider(ctx) + if err != nil { + return resources, fmt.Errorf("failed to search for OIDC provider: %w", err) + } + if oidcProviderARN != "" && !deleted.Has(oidcProviderARN) { + resources.Insert(oidcProviderARN) + } + return resources, nil } +// findOIDCProvider searches for an IAM OIDC provider tagged with the +// cluster's ownership tag. OIDC providers are not discoverable via the +// Resource Groups Tagging API, so we enumerate them and check tags. +func (o *ClusterUninstaller) findOIDCProvider(ctx context.Context) (string, error) { + o.Logger.Debug("search for IAM OIDC provider") + response, err := o.IAMClient.ListOpenIDConnectProviders(ctx, &iamv2.ListOpenIDConnectProvidersInput{}) + if err != nil { + return "", fmt.Errorf("failed to list OIDC providers: %w", err) + } + + for _, provider := range response.OpenIDConnectProviderList { + detail, err := o.IAMClient.GetOpenIDConnectProvider(ctx, &iamv2.GetOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: provider.Arn, + }) + if err != nil { + o.Logger.WithError(err).Debugf("failed to get OIDC provider %s", *provider.Arn) + continue + } + tags := make(map[string]string, len(detail.Tags)) + for _, tag := range detail.Tags { + tags[*tag.Key] = *tag.Value + } + if tagMatch(o.Filters, tags) { + o.Logger.Debugf("found OIDC provider %s", *provider.Arn) + return *provider.Arn, nil + } + } + + return "", nil +} + // findResourcesToDelete returns the resources that should be deleted. // // tagClients - clients of the tagging API to use to search for resources. diff --git a/pkg/destroy/aws/iamhelpers.go b/pkg/destroy/aws/iamhelpers.go index 22eafaa695f..d575460dbef 100644 --- a/pkg/destroy/aws/iamhelpers.go +++ b/pkg/destroy/aws/iamhelpers.go @@ -181,6 +181,8 @@ func (o *ClusterUninstaller) deleteIAM(ctx context.Context, client *iamv2.Client switch resourceType { case "instance-profile": return deleteIAMInstanceProfile(ctx, client, arn, logger) + case "oidc-provider": + return deleteOIDCProvider(ctx, client, arn, logger) case "role": return deleteIAMRole(ctx, client, arn, logger) case "user": @@ -349,6 +351,22 @@ func deleteIAMRole(ctx context.Context, client *iamv2.Client, roleARN arn.ARN, l return nil } +func deleteOIDCProvider(ctx context.Context, client *iamv2.Client, providerARN arn.ARN, logger logrus.FieldLogger) error { + arnString := providerARN.String() + _, err := client.DeleteOpenIDConnectProvider(ctx, &iamv2.DeleteOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arnString, + }) + if err != nil { + if strings.Contains(HandleErrorCode(err), "NoSuchEntity") { + return nil + } + return err + } + + logger.Info("Deleted") + return nil +} + func deleteIAMUser(ctx context.Context, client *iamv2.Client, id string, logger logrus.FieldLogger) error { var lastError error From 13b070715d16d487ead8d456f0873ebeeb7fe791 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 2 Jul 2026 09:53:33 -0700 Subject: [PATCH 8/8] deps: go mod tidy github.com/go-jose/go-jose/v4 is now direct dependency --- go.mod | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 89556efbca6..c010717f79c 100644 --- a/go.mod +++ b/go.mod @@ -154,7 +154,10 @@ require ( require github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute v1.0.0 -require github.com/k-orc/openstack-resource-controller/v2 v2.3.0 +require ( + github.com/go-jose/go-jose/v4 v4.1.4 + github.com/k-orc/openstack-resource-controller/v2 v2.3.0 +) require ( cel.dev/expr v0.25.1 // indirect @@ -168,7 +171,6 @@ require ( github.com/djherbis/times v1.6.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/golangci/plugin-module-register v0.1.2 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect