mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-21 05:55:42 +08:00
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:
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user