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/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 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/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) +} 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/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 } 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/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 } 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 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/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, 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/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 +} 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