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)
}