AWS Roles Anywhere: endpoint to validate name (#57887)

* AWS Roles Anywhere: endpoint to validate name

When the user is configuring the integration, one of the steps is to
give a name to the integration.
After giving the name, the user is asked to run a script.
Only after, the integration is created on Teleport side.

This can cause a bad UX if the user picks an integration name that is
not valid.

This endpoint is going to be called from the UI when the user enters the
integration name.

It ensures the integration does not exist yet, and that it is a valid
name.

* review pt1
This commit is contained in:
Marco Dinis
2025-08-22 11:07:15 +00:00
committed by GitHub
parent 9358db0b48
commit b65d7fda06
7 changed files with 152 additions and 28 deletions
@@ -28,7 +28,6 @@ import (
"github.com/jonboulle/clockwork"
"golang.org/x/crypto/ssh"
"google.golang.org/protobuf/types/known/emptypb"
"k8s.io/apimachinery/pkg/util/validation"
"github.com/gravitational/teleport"
integrationpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1"
@@ -38,6 +37,7 @@ import (
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/integrations/awscommon"
"github.com/gravitational/teleport/lib/integrations/awsra/createsession"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/services"
@@ -248,12 +248,8 @@ func (s *Service) CreateIntegration(ctx context.Context, req *integrationpb.Crea
return nil, trace.Wrap(err)
}
case types.IntegrationSubKindAWSOIDC, types.IntegrationSubKindAWSRolesAnywhere:
// AWS OIDC and Roles Anywhere Integrations can be used as source of credentials to access AWS Web/CLI.
// For OIDC, this creates a new AppServer whose endpoint is <integrationName>.<proxyURL>, which can fail if integrationName is not a valid DNS Label.
// For Roles Anywhere, this creates a AppServers for each Roles Anywhere Profile whose endpoint is <profileName>-<integrationName>.<proxyURL>, which can fail if integrationName is not a valid DNS Label.
// Instead of failing when the integration is already created, it fails at creation time.
if errs := validation.IsDNS1035Label(req.GetIntegration().GetName()); len(errs) > 0 {
return nil, trace.BadParameter("integration name %q must be a lower case valid DNS subdomain so that it can be used to allow Web/CLI access", req.GetIntegration().GetName())
if err := awscommon.ValidIntegratioName(req.Integration.GetName()); err != nil {
return nil, trace.Wrap(err)
}
if err := validateAWSRolesAnywhereProfileFilters(req.Integration); err != nil {
+37
View File
@@ -0,0 +1,37 @@
/*
* 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 awscommon
import (
"github.com/gravitational/trace"
"k8s.io/apimachinery/pkg/util/validation"
)
// ValidIntegratioName validates the integration name.
func ValidIntegratioName(name string) error {
// AWS OIDC and Roles Anywhere Integrations can be used as source of credentials to access AWS Web/CLI.
// For OIDC, this creates a new AppServer whose endpoint is <integrationName>.<proxyURL>, which can fail if integrationName is not a valid DNS Label.
// For Roles Anywhere, this creates a AppServers for each Roles Anywhere Profile whose endpoint is <profileName>-<integrationName>.<proxyURL>, which can fail if integrationName is not a valid DNS Label.
// Instead of failing when the integration is already created, it fails at creation time.
if errs := validation.IsDNS1035Label(name); len(errs) > 0 {
return trace.BadParameter("integration name %q must be a lower case valid DNS subdomain so that it can be used to allow Web/CLI access", name)
}
return nil
}
+1
View File
@@ -1073,6 +1073,7 @@ func (h *Handler) bindDefaultEndpoints() {
// AWS IAM Roles Anywhere Integration Actions
h.GET("/webapi/scripts/integrations/configure/awsra-trust-anchor.sh", h.WithLimiter(h.awsRolesAnywhereConfigureTrustAnchor))
h.POST("/webapi/sites/:site/integrations/aws-ra/:name/validate", h.WithClusterAuth(h.validateAWSRolesAnywhereIntegration))
h.POST("/webapi/sites/:site/integrations/aws-ra/:name/ping", h.WithClusterAuth(h.awsRolesAnywherePing))
h.POST("/webapi/sites/:site/integrations/aws-ra/:name/listprofiles", h.WithClusterAuth(h.awsRolesAnywhereListProfiles))
+6 -6
View File
@@ -149,7 +149,7 @@ func (h *Handler) integrationsCreate(w http.ResponseWriter, r *http.Request, p h
func (h *Handler) integrationsUpdate(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) {
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
var req *ui.UpdateIntegrationRequest
@@ -239,7 +239,7 @@ func (h *Handler) integrationsUpdate(w http.ResponseWriter, r *http.Request, p h
func (h *Handler) integrationsDelete(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) {
integrationName := p.ByName("name_or_subkind")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(r.Context(), cluster)
@@ -262,7 +262,7 @@ func (h *Handler) integrationsDelete(w http.ResponseWriter, r *http.Request, p h
func (h *Handler) integrationsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) {
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(r.Context(), cluster)
@@ -287,7 +287,7 @@ func (h *Handler) integrationsGet(w http.ResponseWriter, r *http.Request, p http
func (h *Handler) integrationStats(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) {
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(r.Context(), cluster)
@@ -490,7 +490,7 @@ func rulesWithIntegration(dc *discoveryconfig.DiscoveryConfig, matcherType strin
func (h *Handler) integrationDiscoveryRules(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) {
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
values := r.URL.Query()
@@ -673,7 +673,7 @@ func (h *Handler) integrationsMsTeamsAppZipGet(w http.ResponseWriter, r *http.Re
func (h *Handler) integrationsExportCA(_ http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) {
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(r.Context(), cluster)
+13 -13
View File
@@ -71,7 +71,7 @@ func (h *Handler) awsOIDCListDatabases(w http.ResponseWriter, r *http.Request, p
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -113,7 +113,7 @@ func (h *Handler) awsOIDCDeployService(w http.ResponseWriter, r *http.Request, p
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -206,7 +206,7 @@ func (h *Handler) awsOIDCDeployDatabaseServices(w http.ResponseWriter, r *http.R
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -281,7 +281,7 @@ func (h *Handler) awsOIDCListDeployedDatabaseService(w http.ResponseWriter, r *h
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
regions, err := regionsForListingDeployedDatabaseService(ctx, r, clt, clt.DiscoveryConfigClient())
@@ -777,7 +777,7 @@ func (h *Handler) awsOIDCEnrollEKSClusters(w http.ResponseWriter, r *http.Reques
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
versionGetter := &handlerVersionGetter{h}
@@ -829,7 +829,7 @@ func (h *Handler) awsOIDCListEKSClusters(w http.ResponseWriter, r *http.Request,
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -863,7 +863,7 @@ func (h *Handler) awsOIDCListSecurityGroups(w http.ResponseWriter, r *http.Reque
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -951,7 +951,7 @@ func (h *Handler) awsOIDCRequiredDatabasesVPCS(w http.ResponseWriter, r *http.Re
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -1069,7 +1069,7 @@ func (h *Handler) awsOIDCCreateAWSAppAccess(w http.ResponseWriter, r *http.Reque
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -1156,7 +1156,7 @@ func (h *Handler) awsOIDCDeleteAWSAppAccess(w http.ResponseWriter, r *http.Reque
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -1390,7 +1390,7 @@ func (h *Handler) awsOIDCListSubnets(w http.ResponseWriter, r *http.Request, p h
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -1436,7 +1436,7 @@ func (h *Handler) awsOIDCListDatabaseVPCs(w http.ResponseWriter, r *http.Request
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
clt, err := sctx.GetUserClient(ctx, cluster)
@@ -1558,7 +1558,7 @@ func (h *Handler) awsOIDCPing(w http.ResponseWriter, r *http.Request, p httprout
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
var req ui.AWSOIDCPingRequest
+37 -2
View File
@@ -34,6 +34,7 @@ import (
"github.com/gravitational/teleport/api/utils/aws"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/httplib"
"github.com/gravitational/teleport/lib/integrations/awscommon"
"github.com/gravitational/teleport/lib/reversetunnelclient"
"github.com/gravitational/teleport/lib/web/scripts/oneoff"
"github.com/gravitational/teleport/lib/web/ui"
@@ -144,6 +145,40 @@ func (h *Handler) awsRolesAnywhereConfigureTrustAnchor(w http.ResponseWriter, r
return nil, trace.Wrap(err)
}
// validateAWSRolesAnywhereIntegration performs a validation for the AWS Roles Anywhere Integration name.
// This ensures the integration name is not yet being used and that it is a valid name.
func (h *Handler) validateAWSRolesAnywhereIntegration(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) {
ctx := r.Context()
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("integration name is required")
}
// validate integration name.
if err := awscommon.ValidIntegratioName(integrationName); err != nil {
return nil, trace.Wrap(err)
}
clt, err := sctx.GetUserClient(ctx, cluster)
if err != nil {
return nil, trace.Wrap(err)
}
_, err = clt.GetIntegration(ctx, integrationName)
switch {
case err == nil:
return nil, trace.AlreadyExists("integration named %q already exists", integrationName)
case trace.IsNotFound(err):
default:
return nil, trace.Wrap(err)
}
return OK(), nil
}
// awsRolesAnywherePing performs an health check for the integration.
// It returns the caller identity and the number of AWS Roles Anywhere Profiles that are active.
// If a trust anchor is provided in the body, it will be used to check the connection ignoring the integration.
@@ -153,7 +188,7 @@ func (h *Handler) awsRolesAnywherePing(w http.ResponseWriter, r *http.Request, p
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
var req ui.AWSRolesAnywherePingRequest
@@ -209,7 +244,7 @@ func (h *Handler) awsRolesAnywhereListProfiles(w http.ResponseWriter, r *http.Re
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
return nil, trace.BadParameter("integration name is required")
}
var req ui.AWSRolesAnywhereListProfilesRequest
+55
View File
@@ -25,6 +25,9 @@ import (
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/services"
)
func TestBuildAWSRATrustAnchorConfigureScript(t *testing.T) {
@@ -124,3 +127,55 @@ func TestBuildAWSRATrustAnchorConfigureScript(t *testing.T) {
})
}
}
func TestValidateAWSRolesAnywhereIntegration(t *testing.T) {
t.Parallel()
wPack := newWebPack(t, 1 /* proxies */)
proxy := wPack.proxies[0]
authPack := proxy.authPack(t, "user", []types.Role{services.NewPresetEditorRole()})
ctx := t.Context()
existingIntegration, err := types.NewIntegrationAWSOIDC(types.Metadata{
Name: "existing-integration",
}, &types.AWSOIDCIntegrationSpecV1{
RoleARN: "arn:aws:iam::123456789012:role/valid-role",
})
require.NoError(t, err)
_, err = wPack.server.Auth().CreateIntegration(ctx, existingIntegration)
require.NoError(t, err)
for _, tt := range []struct {
name string
integrationName string
errCheck require.ErrorAssertionFunc
}{
{
name: "valid",
integrationName: "valid-integration",
errCheck: require.NoError,
},
{
name: "invalid",
integrationName: "INVALID-",
errCheck: func(tt require.TestingT, err error, i ...interface{}) {
require.Error(tt, err)
require.ErrorContains(tt, err, "must be a lower case valid DNS subdomain")
},
},
{
name: "valid name but it already exists",
integrationName: "existing-integration",
errCheck: func(tt require.TestingT, err error, i ...interface{}) {
require.Error(tt, err)
require.ErrorContains(tt, err, "already exists")
},
},
} {
t.Run(tt.name, func(t *testing.T) {
endpoint := authPack.clt.Endpoint("webapi", "sites", wPack.server.ClusterName(), "integrations", "aws-ra", tt.integrationName, "validate")
_, err := authPack.clt.PostJSON(ctx, endpoint, nil)
tt.errCheck(t, err)
})
}
}