diff --git a/api/utils/aws/identifiers.go b/api/utils/aws/identifiers.go index fd71aaf22b8..ccacd4c9333 100644 --- a/api/utils/aws/identifiers.go +++ b/api/utils/aws/identifiers.go @@ -53,6 +53,28 @@ func IsValidIAMRoleName(roleName string) error { return nil } +// IsValidIAMRolesAnywhereTrustAnchorName checks whether the AWS IAM Roles Anywhere Trust Anchor name is valid. +// Validation based on the AWS documentation. +// See https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateTrustAnchor.html#API_CreateTrustAnchor_RequestBody +func IsValidIAMRolesAnywhereTrustAnchorName(name string) error { + if !matchRolesAnywhereTrustAnchorName(name) { + return trace.BadParameter("trust anchor name is invalid") + } + + return nil +} + +// IsValidIAMRolesAnywhereProfileName checks whether the AWS IAM Roles Anywhere Profile name is valid. +// Validation based on the AWS documentation. +// See https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateProfile.html#API_CreateProfile_RequestBody +func IsValidIAMRolesAnywhereProfileName(name string) error { + if !matchRolesAnywhereProfileName(name) { + return trace.BadParameter("profile name is invalid") + } + + return nil +} + // IsValidIAMPolicyName checks whether the policy name is a valid AWS IAM Policy // identifier. // @@ -180,6 +202,16 @@ var ( // > special characters other than underscore (_) are not supported matchGlueName = regexp.MustCompile(`^[a-z0-9_]{1,255}$`).MatchString + // matchRolesAnywhereTrustAnchorName is a regex that matches against AWS IAM Roles Anywhere Trust Anchor Names. + // See https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateTrustAnchor.html#API_CreateTrustAnchor_RequestBody + matchRolesAnywhereTrustAnchorName = baseResourceNameMatcher + + // matchRolesAnywhereProfileName is a regex that matches against AWS IAM Roles Anywhere Trust Anchor Names. + // See https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateProfile.html#API_CreateProfile_RequestBody + matchRolesAnywhereProfileName = baseResourceNameMatcher + + baseResourceNameMatcher = regexp.MustCompile(`^[ a-zA-Z0-9-_]{1,255}$`).MatchString + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html validPartitions = []string{"aws", "aws-cn", "aws-us-gov"} ) diff --git a/api/utils/aws/identifiers_test.go b/api/utils/aws/identifiers_test.go index a6c701a2e10..666b8b12313 100644 --- a/api/utils/aws/identifiers_test.go +++ b/api/utils/aws/identifiers_test.go @@ -391,3 +391,69 @@ func TestIsValidGlueResourceName(t *testing.T) { }) } } + +func TestIsValidIAMRolesAnywhereTrustAnchorName(t *testing.T) { + for _, tt := range []struct { + name string + trustAnchorName string + errCheck require.ErrorAssertionFunc + }{ + { + name: "valid", + trustAnchorName: "aA0-_", + errCheck: require.NoError, + }, + { + name: "empty", + trustAnchorName: "", + errCheck: require.Error, + }, + { + name: "too long", + trustAnchorName: strings.Repeat("a", 256), + errCheck: require.Error, + }, + { + name: "invalid chars", + trustAnchorName: "+", + errCheck: require.Error, + }, + } { + t.Run(tt.name, func(t *testing.T) { + tt.errCheck(t, IsValidIAMRolesAnywhereTrustAnchorName(tt.trustAnchorName)) + }) + } +} + +func TestIsValidIAMRolesAnywhereProfileName(t *testing.T) { + for _, tt := range []struct { + name string + profileName string + errCheck require.ErrorAssertionFunc + }{ + { + name: "valid", + profileName: "aA0-_", + errCheck: require.NoError, + }, + { + name: "empty", + profileName: "", + errCheck: require.Error, + }, + { + name: "too long", + profileName: strings.Repeat("a", 256), + errCheck: require.Error, + }, + { + name: "invalid chars", + profileName: "+", + errCheck: require.Error, + }, + } { + t.Run(tt.name, func(t *testing.T) { + tt.errCheck(t, IsValidIAMRolesAnywhereProfileName(tt.profileName)) + }) + } +} diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index d60cf266b82..f8ae37957a0 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1057,6 +1057,9 @@ func (h *Handler) bindDefaultEndpoints() { h.DELETE("/webapi/sites/:site/integrations/:name_or_subkind/aws-app-access/:name", h.WithClusterAuth(h.awsOIDCDeleteAWSAppAccess)) h.GET("/webapi/scripts/integrations/configure/ec2-ssm-iam.sh", h.WithLimiter(h.awsOIDCConfigureEC2SSMIAM)) + // AWS IAM Roles Anywhere Integration Actions + h.GET("/webapi/scripts/integrations/configure/awsra-trust-anchor.sh", h.WithLimiter(h.awsRolesAnywhereConfigureTrustAnchor)) + // SAML IDP integration endpoints h.GET("/webapi/scripts/integrations/configure/gcp-workforce-saml.sh", h.WithLimiter(h.gcpWorkforceConfigScript)) diff --git a/lib/web/integrations_awsoidc.go b/lib/web/integrations_awsoidc.go index eb36e3700eb..98f0655ecb5 100644 --- a/lib/web/integrations_awsoidc.go +++ b/lib/web/integrations_awsoidc.go @@ -608,7 +608,7 @@ func (h *Handler) awsOIDCConfigureDeployServiceIAM(w http.ResponseWriter, r *htt } httplib.SetScriptHeaders(w.Header()) - _, err = fmt.Fprint(w, script) + _, err = w.Write([]byte(script)) return nil, trace.Wrap(err) } @@ -641,8 +641,8 @@ func (h *Handler) awsOIDCConfigureAWSAppAccessIAM(w http.ResponseWriter, r *http } httplib.SetScriptHeaders(w.Header()) + _, err = w.Write([]byte(script)) - _, err = fmt.Fprint(w, script) return nil, trace.Wrap(err) } @@ -712,8 +712,8 @@ func (h *Handler) awsOIDCConfigureEC2SSMIAM(w http.ResponseWriter, r *http.Reque } httplib.SetScriptHeaders(w.Header()) + _, err = w.Write([]byte(script)) - _, err = fmt.Fprint(w, script) return nil, trace.Wrap(err) } @@ -753,7 +753,7 @@ func (h *Handler) awsOIDCConfigureEKSIAM(w http.ResponseWriter, r *http.Request, } httplib.SetScriptHeaders(w.Header()) - _, err = fmt.Fprint(w, script) + _, err = w.Write([]byte(script)) return nil, trace.Wrap(err) } @@ -1260,7 +1260,7 @@ func (h *Handler) awsOIDCConfigureIdP(w http.ResponseWriter, r *http.Request, p } httplib.SetScriptHeaders(w.Header()) - _, err = fmt.Fprint(w, script) + _, err = w.Write([]byte(script)) return nil, trace.Wrap(err) } @@ -1301,7 +1301,7 @@ func (h *Handler) awsOIDCConfigureListDatabasesIAM(w http.ResponseWriter, r *htt } httplib.SetScriptHeaders(w.Header()) - _, err = fmt.Fprint(w, script) + _, err = w.Write([]byte(script)) return nil, trace.Wrap(err) } @@ -1347,7 +1347,7 @@ func (h *Handler) awsAccessGraphOIDCSync(w http.ResponseWriter, r *http.Request, } httplib.SetScriptHeaders(w.Header()) - _, err = fmt.Fprint(w, script) + _, err = w.Write([]byte(script)) return nil, trace.Wrap(err) } diff --git a/lib/web/integrations_awsra.go b/lib/web/integrations_awsra.go new file mode 100644 index 00000000000..9270784725a --- /dev/null +++ b/lib/web/integrations_awsra.go @@ -0,0 +1,142 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "bytes" + "encoding/base64" + "fmt" + "net/http" + "strings" + + "github.com/google/safetext/shsprintf" + "github.com/gravitational/trace" + "github.com/julienschmidt/httprouter" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/aws" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/httplib" + "github.com/gravitational/teleport/lib/web/scripts/oneoff" +) + +// awsRolesAnywhereConfigureTrustAnchor returns a script that configures AWS IAM Roles Anywhere Integration +// by creating: +// - IAM Roles Anywhere Trust Anchor which trusts the Teleport AWS RA CA +// - Roles Anywhere to Apps sync process: +// - IAM Role which can be assumed by the Trust Anchor and allows the APIs required by the sync process +// - IAM Roles Anywhere Profile which allows access to the IAM Role above +// +// It requires the following query parameters: +// - integrationName: the name of the AWS IAM Roles Anywhere Integration +// - trustAnchor: the name of the Trust Anchor to be created +// - syncRole: the name of the IAM Role to be created +// - syncProfile: the name of the IAM Roles Anywhere Profile to be created +func (h *Handler) awsRolesAnywhereConfigureTrustAnchor(w http.ResponseWriter, r *http.Request, p httprouter.Params) (any, error) { + ctx := r.Context() + + queryParams := r.URL.Query() + + integrationName := queryParams.Get("integrationName") + if integrationName == "" { + return nil, trace.BadParameter("missing integrationName param") + } + + trustAnchorName := queryParams.Get("trustAnchor") + if trustAnchorName == "" { + return nil, trace.BadParameter("missing trustAnchor param") + } + if err := aws.IsValidIAMRolesAnywhereTrustAnchorName(trustAnchorName); err != nil { + return nil, trace.BadParameter("invalid trustAnchor %q", trustAnchorName) + } + + syncRoleName := queryParams.Get("syncRole") + if syncRoleName == "" { + return nil, trace.BadParameter("missing syncRole param") + } + if err := aws.IsValidIAMRoleName(syncRoleName); err != nil { + return nil, trace.BadParameter("invalid role %q", syncRoleName) + } + + syncProfileName := queryParams.Get("syncProfile") + if syncProfileName == "" { + return nil, trace.BadParameter("missing syncProfile param") + } + if err := aws.IsValidIAMRolesAnywhereProfileName(syncProfileName); err != nil { + return nil, trace.BadParameter("invalid syncProfile %q", syncProfileName) + } + + clusterName, err := h.GetProxyClient().GetDomainName(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + // Ensure the IntegrationName is valid. + _, err = h.GetProxyClient().GetIntegration(ctx, integrationName) + // NotFound error is ignored to prevent disclosure of whether the integration exists in a public/no-auth endpoint. + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } + + authorities, err := client.ExportAllAuthorities( + ctx, + h.GetProxyClient(), + client.ExportAuthoritiesRequest{ + AuthType: string(types.AWSRACA), + }, + ) + if err != nil { + return nil, trace.Wrap(err) + } + if len(authorities) == 0 { + return nil, trace.NotFound("no AWS IAM Roles Anywhere CA found") + } + + var certAuthoritiesData [][]byte + for _, authority := range authorities { + certAuthoritiesData = append(certAuthoritiesData, authority.Data) + } + + awsRACACertB64 := base64.RawStdEncoding.EncodeToString(bytes.Join(certAuthoritiesData, []byte("\n"))) + + // The script must execute the following command: + // teleport integration configure awsra-trust-anchor + argsList := []string{ + "integration", "configure", "awsra-trust-anchor", + fmt.Sprintf("--cluster=%s", shsprintf.EscapeDefaultContext(clusterName)), + fmt.Sprintf("--name=%s", shsprintf.EscapeDefaultContext(integrationName)), + fmt.Sprintf("--trust-anchor=%s", shsprintf.EscapeDefaultContext(trustAnchorName)), + fmt.Sprintf("--sync-profile=%s", shsprintf.EscapeDefaultContext(syncProfileName)), + fmt.Sprintf("--sync-role=%s", shsprintf.EscapeDefaultContext(syncRoleName)), + fmt.Sprintf("--trust-anchor-cert-b64=%s", awsRACACertB64), + } + + script, err := oneoff.BuildScript(oneoff.OneOffScriptParams{ + EntrypointArgs: strings.Join(argsList, " "), + SuccessMessage: "Success! You can now go back to the Teleport Web UI to continue the setup.", + }) + if err != nil { + return nil, trace.Wrap(err) + } + + httplib.SetScriptHeaders(w.Header()) + _, err = w.Write([]byte(script)) + + return nil, trace.Wrap(err) +} diff --git a/lib/web/integrations_awsra_test.go b/lib/web/integrations_awsra_test.go new file mode 100644 index 00000000000..872747aeb8f --- /dev/null +++ b/lib/web/integrations_awsra_test.go @@ -0,0 +1,126 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "context" + "net/url" + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" +) + +func TestBuildAWSRATrustAnchorConfigureScript(t *testing.T) { + t.Parallel() + isBadParamErrFn := func(tt require.TestingT, err error, i ...any) { + require.True(tt, trace.IsBadParameter(err), "expected bad parameter, got %v", err) + } + + ctx := context.Background() + env := newWebPack(t, 1) + + // Unauthenticated client for script downloading. + publicClt := env.proxies[0].newClient(t) + pathVars := []string{ + "webapi", + "scripts", + "integrations", + "configure", + "awsra-trust-anchor.sh", + } + scriptEndpoint := publicClt.Endpoint(pathVars...) + + baseQueryParams := func() url.Values { + return url.Values{ + "integrationName": []string{"myintegration"}, + "trustAnchor": []string{"my-trust-anchor"}, + "syncProfile": []string{"my-profile-for-sync"}, + "syncRole": []string{"my-role-for-sync"}, + } + } + + tests := []struct { + name string + reqRelativeURL string + reqQuery func() url.Values + errCheck require.ErrorAssertionFunc + expectedTeleportArgs string + }{ + { + name: "valid", + reqQuery: baseQueryParams, + errCheck: require.NoError, + expectedTeleportArgs: "integration configure awsra-trust-anchor " + + "--cluster=localhost " + + "--name=myintegration " + + "--trust-anchor=my-trust-anchor " + + "--sync-profile=my-profile-for-sync " + + "--sync-role=my-role-for-sync ", + }, + { + name: "missing integration name", + reqQuery: func() url.Values { + q := baseQueryParams() + q.Del("integrationName") + return q + }, + errCheck: isBadParamErrFn, + }, + { + name: "missing trust anchor", + reqQuery: func() url.Values { + q := baseQueryParams() + q.Del("trustAnchor") + return q + }, + errCheck: isBadParamErrFn, + }, + { + name: "missing sync profile name", + reqQuery: func() url.Values { + q := baseQueryParams() + q.Del("syncProfile") + return q + }, + errCheck: isBadParamErrFn, + }, + { + name: "missing sync role name", + reqQuery: func() url.Values { + q := baseQueryParams() + q.Del("syncRole") + return q + }, + errCheck: isBadParamErrFn, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp, err := publicClt.Get(ctx, scriptEndpoint, tc.reqQuery()) + tc.errCheck(t, err) + if err != nil { + return + } + + require.Contains(t, string(resp.Bytes()), "entrypointArgs='"+tc.expectedTeleportArgs) + }) + } +} diff --git a/lib/web/integrations_azureoidc.go b/lib/web/integrations_azureoidc.go index 3a1dd654550..24886cd7dc8 100644 --- a/lib/web/integrations_azureoidc.go +++ b/lib/web/integrations_azureoidc.go @@ -74,7 +74,7 @@ func (h *Handler) azureOIDCConfigure(w http.ResponseWriter, r *http.Request, p h } httplib.SetScriptHeaders(w.Header()) - _, err = fmt.Fprint(w, script) + _, err = w.Write([]byte(script)) return nil, trace.Wrap(err) } diff --git a/lib/web/integrations_samlidp.go b/lib/web/integrations_samlidp.go index eda5dac78a2..77ee47022f8 100644 --- a/lib/web/integrations_samlidp.go +++ b/lib/web/integrations_samlidp.go @@ -64,7 +64,7 @@ func (h *Handler) gcpWorkforceConfigScript(w http.ResponseWriter, r *http.Reques } httplib.SetScriptHeaders(w.Header()) - _, err = fmt.Fprint(w, script) + _, err = w.Write([]byte(script)) return nil, trace.Wrap(err) }