WebAPI: endpoint that returns the oneoff command for AWS Roles Anywhere set up (#54853)

* WebAPI: oneoff script for AWS Roles Anywhere set up

* use w.Write instead of fmt.Fprint
This commit is contained in:
Marco Dinis
2025-05-28 08:30:03 +00:00
committed by GitHub
parent c21fda1ad3
commit c5c94aee18
8 changed files with 378 additions and 9 deletions
+32
View File
@@ -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"}
)
+66
View File
@@ -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))
})
}
}
+3
View File
@@ -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))
+7 -7
View File
@@ -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)
}
+142
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
+126
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
})
}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}