mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
Port gcp join method to new join service (#61205)
* Port `gcp` join method to new join service This ports the `gcp` join method to the new join service. It moves join-related code from `lib/gcp` to `lib/join/gcp`, extracts common joining logic to `lib/join/gcp` for reuse between the legacy and new entrypoints, and adds a compatibility layer for use by the legacy client. See also: [RFD 27e](https://github.com/gravitational/teleport.e/blob/master/rfd/0027e-auth-assigned-uuids.md) * Add GCP to new join service whitelist * Rename checkAndSetDefaults() to validate()
This commit is contained in:
+2
-2
@@ -111,7 +111,6 @@ import (
|
||||
"github.com/gravitational/teleport/lib/devicetrust/assertserver"
|
||||
dtconfig "github.com/gravitational/teleport/lib/devicetrust/config"
|
||||
"github.com/gravitational/teleport/lib/events"
|
||||
"github.com/gravitational/teleport/lib/gcp"
|
||||
"github.com/gravitational/teleport/lib/integrations/awsra/createsession"
|
||||
"github.com/gravitational/teleport/lib/inventory"
|
||||
iterstream "github.com/gravitational/teleport/lib/itertools/stream"
|
||||
@@ -120,6 +119,7 @@ import (
|
||||
joinboundkeypair "github.com/gravitational/teleport/lib/join/boundkeypair"
|
||||
"github.com/gravitational/teleport/lib/join/ec2join"
|
||||
"github.com/gravitational/teleport/lib/join/env0"
|
||||
"github.com/gravitational/teleport/lib/join/gcp"
|
||||
"github.com/gravitational/teleport/lib/join/githubactions"
|
||||
"github.com/gravitational/teleport/lib/join/gitlab"
|
||||
kubetoken "github.com/gravitational/teleport/lib/kube/token"
|
||||
@@ -1293,7 +1293,7 @@ type Server struct {
|
||||
|
||||
// gcpIDTokenValidator allows ID tokens from GCP to be validated by the auth
|
||||
// server. It can be overridden for the purpose of tests.
|
||||
gcpIDTokenValidator gcpIDTokenValidator
|
||||
gcpIDTokenValidator gcp.Validator
|
||||
|
||||
// terraformIDTokenValidator allows JWTs from Terraform Cloud to be
|
||||
// validated by the auth server using a known JWKS. It can be overridden for
|
||||
|
||||
@@ -205,10 +205,6 @@ func (a *Server) SetCircleCITokenValidate(validator func(ctx context.Context, or
|
||||
a.circleCITokenValidate = validator
|
||||
}
|
||||
|
||||
func (a *Server) SetGCPIDTokenValidator(validator gcpIDTokenValidator) {
|
||||
a.gcpIDTokenValidator = validator
|
||||
}
|
||||
|
||||
func (a *Server) SetK8sTokenReviewValidator(validator k8sTokenReviewValidator) {
|
||||
a.k8sTokenReviewValidator = validator
|
||||
}
|
||||
@@ -361,10 +357,6 @@ func ValidateGithubAuthCallbackHelper(ctx context.Context, m GitHubManager, diag
|
||||
return validateGithubAuthCallbackHelper(ctx, m, diagCtx, q, emitter, logger)
|
||||
}
|
||||
|
||||
func IsGCPZoneInLocation(rawLocation, rawZone string) bool {
|
||||
return isGCPZoneInLocation(rawLocation, rawZone)
|
||||
}
|
||||
|
||||
func FormatHeaderFromMap(m map[string]string) http.Header {
|
||||
return formatHeaderFromMap(m)
|
||||
}
|
||||
|
||||
@@ -346,9 +346,11 @@ func Register(ctx context.Context, params RegisterParams) (result *RegisterResul
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
case types.JoinMethodGCP:
|
||||
params.IDToken, err = gcp.GetIDToken(ctx)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
if params.IDToken == "" {
|
||||
params.IDToken, err = gcp.GetIDToken(ctx)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
}
|
||||
case types.JoinMethodSpacelift:
|
||||
params.IDToken, err = spacelift.NewIDTokenSource(os.Getenv).GetIDToken()
|
||||
|
||||
+18
-112
@@ -20,17 +20,22 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/lib/gcp"
|
||||
"github.com/gravitational/teleport/lib/join/gcp"
|
||||
)
|
||||
|
||||
type gcpIDTokenValidator interface {
|
||||
Validate(ctx context.Context, token string) (*gcp.IDTokenClaims, error)
|
||||
// GetGCPIDTokenValidator returns the server's configured GCP ID token
|
||||
// validator.
|
||||
func (a *Server) GetGCPIDTokenValidator() gcp.Validator {
|
||||
return a.gcpIDTokenValidator
|
||||
}
|
||||
|
||||
// SetGCPIDTokenValidator sets a new GCP ID token validator, used in tests.
|
||||
func (a *Server) SetGCPIDTokenValidator(validator gcp.Validator) {
|
||||
a.gcpIDTokenValidator = validator
|
||||
}
|
||||
|
||||
func (a *Server) checkGCPJoinRequest(
|
||||
@@ -38,112 +43,13 @@ func (a *Server) checkGCPJoinRequest(
|
||||
req *types.RegisterUsingTokenRequest,
|
||||
pt types.ProvisionToken,
|
||||
) (*gcp.IDTokenClaims, error) {
|
||||
if req.IDToken == "" {
|
||||
return nil, trace.BadParameter("IDToken not provided for GCP join request")
|
||||
}
|
||||
token, ok := pt.(*types.ProvisionTokenV2)
|
||||
if !ok {
|
||||
return nil, trace.BadParameter("gcp join method only supports ProvisionTokenV2, '%T' was provided", pt)
|
||||
}
|
||||
claims, err := gcp.CheckIDToken(ctx, &gcp.CheckIDTokenParams{
|
||||
ProvisionToken: pt,
|
||||
IDToken: []byte(req.IDToken),
|
||||
Validator: a.gcpIDTokenValidator,
|
||||
})
|
||||
|
||||
claims, err := a.gcpIDTokenValidator.Validate(ctx, req.IDToken)
|
||||
if err != nil {
|
||||
a.logger.WarnContext(ctx, "Unable to validate GCP IDToken",
|
||||
"error", err,
|
||||
"claims", claims,
|
||||
"token", pt.GetName(),
|
||||
)
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
a.logger.InfoContext(ctx, "GCP VM trying to join cluster",
|
||||
"claims", claims,
|
||||
"token", pt.GetName(),
|
||||
)
|
||||
|
||||
if err := checkGCPAllowRules(token, claims); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func checkGCPAllowRules(token *types.ProvisionTokenV2, claims *gcp.IDTokenClaims) error {
|
||||
compute := claims.Google.ComputeEngine
|
||||
// unmatchedLocation is true if the location restriction is set and the "google.compute_engine.zone"
|
||||
// claim is not present in the IDToken. This happens when the joining node is not a GCE VM.
|
||||
unmatchedLocation := false
|
||||
// If a single rule passes, accept the IDToken.
|
||||
for _, rule := range token.Spec.GCP.Allow {
|
||||
if !slices.Contains(rule.ProjectIDs, compute.ProjectID) {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(rule.ServiceAccounts) > 0 && !slices.Contains(rule.ServiceAccounts, claims.Email) {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(rule.Locations) > 0 && !slices.ContainsFunc(rule.Locations, func(location string) bool {
|
||||
return isGCPZoneInLocation(location, compute.Zone)
|
||||
}) {
|
||||
unmatchedLocation = true
|
||||
continue
|
||||
}
|
||||
|
||||
// All provided rules met.
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the location restriction is set and the "google.compute_engine.zone" claim is not present in the IDToken,
|
||||
// return a more specific error message.
|
||||
if unmatchedLocation && compute.Zone == "" {
|
||||
return trace.CompareFailed("id token %q claim is empty and didn't match the %q. "+
|
||||
"Services running outside of GCE VM instances are incompatible with %q restriction.", "google.compute_engine.zone", "locations", "location")
|
||||
}
|
||||
return trace.AccessDenied("id token claims did not match any allow rules")
|
||||
}
|
||||
|
||||
type gcpLocation struct {
|
||||
globalLocation string
|
||||
region string
|
||||
zone string
|
||||
}
|
||||
|
||||
func parseGCPLocation(location string) (*gcpLocation, error) {
|
||||
parts := strings.Split(location, "-")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return nil, trace.BadParameter("location %q is not a valid GCP region or zone", location)
|
||||
}
|
||||
globalLocation, region := parts[0], parts[1]
|
||||
var zone string
|
||||
if len(parts) == 3 {
|
||||
zone = parts[2]
|
||||
}
|
||||
return &gcpLocation{
|
||||
globalLocation: globalLocation,
|
||||
region: region,
|
||||
zone: zone,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isGCPZoneInLocation checks if a zone belongs to a location, which can be
|
||||
// either a zone or region.
|
||||
func isGCPZoneInLocation(rawLocation, rawZone string) bool {
|
||||
location, err := parseGCPLocation(rawLocation)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
zone, err := parseGCPLocation(rawZone)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Make sure zone is, in fact, a zone.
|
||||
if zone.zone == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if location.globalLocation != zone.globalLocation || location.region != zone.region {
|
||||
return false
|
||||
}
|
||||
return location.zone == "" || location.zone == zone.zone
|
||||
// Where possible, try to return any extracted claims along with the error
|
||||
// to improve audit logs for failed join attempts.
|
||||
return claims, trace.Wrap(err)
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2023 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 gcp
|
||||
|
||||
import (
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
|
||||
workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
|
||||
)
|
||||
|
||||
// defaultIssuerHost is the issuer for GCP ID tokens.
|
||||
const defaultIssuerHost = "accounts.google.com"
|
||||
|
||||
// ComputeEngine contains VM-specific token claims.
|
||||
type ComputeEngine struct {
|
||||
// The ID of the instance's project.
|
||||
ProjectID string `json:"project_id"`
|
||||
// The instance's zone.
|
||||
Zone string `json:"zone"`
|
||||
// The instance's ID.
|
||||
InstanceID string `json:"instance_id"`
|
||||
// The instance's name.
|
||||
InstanceName string `json:"instance_name"`
|
||||
}
|
||||
|
||||
// Google contains Google-specific token claims.
|
||||
type Google struct {
|
||||
ComputeEngine ComputeEngine `json:"compute_engine"`
|
||||
}
|
||||
|
||||
// IDTokenClaims is the set of claims in a GCP ID token. GCP documentation for
|
||||
// claims can be found at
|
||||
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity#payload
|
||||
type IDTokenClaims struct {
|
||||
oidc.TokenClaims
|
||||
// The email of the service account that this token was issued for.
|
||||
Email string `json:"email"`
|
||||
Google Google `json:"google"`
|
||||
}
|
||||
|
||||
// JoinAttrs returns the protobuf representation of the attested identity.
|
||||
// This is used for auditing and for evaluation of WorkloadIdentity rules and
|
||||
// templating.
|
||||
func (c *IDTokenClaims) JoinAttrs() *workloadidentityv1pb.JoinAttrsGCP {
|
||||
attrs := &workloadidentityv1pb.JoinAttrsGCP{
|
||||
ServiceAccount: c.Email,
|
||||
}
|
||||
if c.Google.ComputeEngine.InstanceName != "" {
|
||||
attrs.Gce = &workloadidentityv1pb.JoinAttrsGCPGCE{
|
||||
Project: c.Google.ComputeEngine.ProjectID,
|
||||
Zone: c.Google.ComputeEngine.Zone,
|
||||
Id: c.Google.ComputeEngine.InstanceID,
|
||||
Name: c.Google.ComputeEngine.InstanceName,
|
||||
}
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2023 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 gcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
|
||||
"github.com/gravitational/teleport"
|
||||
workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/lib/join/provision"
|
||||
logutils "github.com/gravitational/teleport/lib/utils/log"
|
||||
)
|
||||
|
||||
var log = logutils.NewPackageLogger(teleport.ComponentKey, "gcp")
|
||||
|
||||
// Validator is an interface for GCP token validators, used in tests to provide
|
||||
// mock implementations.
|
||||
type Validator interface {
|
||||
Validate(ctx context.Context, token string) (*IDTokenClaims, error)
|
||||
}
|
||||
|
||||
// defaultIssuerHost is the issuer for GCP ID tokens.
|
||||
const defaultIssuerHost = "accounts.google.com"
|
||||
|
||||
// ComputeEngine contains VM-specific token claims.
|
||||
type ComputeEngine struct {
|
||||
// The ID of the instance's project.
|
||||
ProjectID string `json:"project_id"`
|
||||
// The instance's zone.
|
||||
Zone string `json:"zone"`
|
||||
// The instance's ID.
|
||||
InstanceID string `json:"instance_id"`
|
||||
// The instance's name.
|
||||
InstanceName string `json:"instance_name"`
|
||||
}
|
||||
|
||||
// Google contains Google-specific token claims.
|
||||
type Google struct {
|
||||
ComputeEngine ComputeEngine `json:"compute_engine"`
|
||||
}
|
||||
|
||||
// IDTokenClaims is the set of claims in a GCP ID token. GCP documentation for
|
||||
// claims can be found at
|
||||
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity#payload
|
||||
type IDTokenClaims struct {
|
||||
oidc.TokenClaims
|
||||
// The email of the service account that this token was issued for.
|
||||
Email string `json:"email"`
|
||||
Google Google `json:"google"`
|
||||
}
|
||||
|
||||
// JoinAttrs returns the protobuf representation of the attested identity.
|
||||
// This is used for auditing and for evaluation of WorkloadIdentity rules and
|
||||
// templating.
|
||||
func (c *IDTokenClaims) JoinAttrs() *workloadidentityv1pb.JoinAttrsGCP {
|
||||
attrs := &workloadidentityv1pb.JoinAttrsGCP{
|
||||
ServiceAccount: c.Email,
|
||||
}
|
||||
if c.Google.ComputeEngine.InstanceName != "" {
|
||||
attrs.Gce = &workloadidentityv1pb.JoinAttrsGCPGCE{
|
||||
Project: c.Google.ComputeEngine.ProjectID,
|
||||
Zone: c.Google.ComputeEngine.Zone,
|
||||
Id: c.Google.ComputeEngine.InstanceID,
|
||||
Name: c.Google.ComputeEngine.InstanceName,
|
||||
}
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// CheckIDTokenParams are parameters used to validate GCP OIDC tokens.
|
||||
type CheckIDTokenParams struct {
|
||||
ProvisionToken provision.Token
|
||||
IDToken []byte
|
||||
Validator Validator
|
||||
}
|
||||
|
||||
func (p *CheckIDTokenParams) validate() error {
|
||||
switch {
|
||||
case p.ProvisionToken == nil:
|
||||
return trace.BadParameter("ProvisionToken is required")
|
||||
case len(p.IDToken) == 0:
|
||||
return trace.BadParameter("IDToken is required")
|
||||
case p.Validator == nil:
|
||||
return trace.BadParameter("Validator is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckIDToken verifies a GCP OIDC token
|
||||
func CheckIDToken(
|
||||
ctx context.Context,
|
||||
params *CheckIDTokenParams,
|
||||
) (*IDTokenClaims, error) {
|
||||
if err := params.validate(); err != nil {
|
||||
return nil, trace.AccessDenied("%s", err.Error())
|
||||
}
|
||||
|
||||
token, ok := params.ProvisionToken.(*types.ProvisionTokenV2)
|
||||
if !ok {
|
||||
return nil, trace.BadParameter("gcp join method only supports ProvisionTokenV2, '%T' was provided", params.ProvisionToken)
|
||||
}
|
||||
|
||||
claims, err := params.Validator.Validate(ctx, string(params.IDToken))
|
||||
if err != nil {
|
||||
log.WarnContext(ctx, "Unable to validate GCP IDToken",
|
||||
"error", err,
|
||||
"claims", claims,
|
||||
"token", params.ProvisionToken.GetName(),
|
||||
)
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
log.InfoContext(ctx, "GCP VM trying to join cluster",
|
||||
"claims", claims,
|
||||
"token", params.ProvisionToken.GetName(),
|
||||
)
|
||||
|
||||
// Note: try to return claims even in case of error to improve logging on
|
||||
// failed auth attempts.
|
||||
return claims, trace.Wrap(checkGCPAllowRules(token, claims))
|
||||
}
|
||||
|
||||
func checkGCPAllowRules(token *types.ProvisionTokenV2, claims *IDTokenClaims) error {
|
||||
compute := claims.Google.ComputeEngine
|
||||
// unmatchedLocation is true if the location restriction is set and the "google.compute_engine.zone"
|
||||
// claim is not present in the IDToken. This happens when the joining node is not a GCE VM.
|
||||
unmatchedLocation := false
|
||||
// If a single rule passes, accept the IDToken.
|
||||
for _, rule := range token.Spec.GCP.Allow {
|
||||
if !slices.Contains(rule.ProjectIDs, compute.ProjectID) {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(rule.ServiceAccounts) > 0 && !slices.Contains(rule.ServiceAccounts, claims.Email) {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(rule.Locations) > 0 && !slices.ContainsFunc(rule.Locations, func(location string) bool {
|
||||
return isGCPZoneInLocation(location, compute.Zone)
|
||||
}) {
|
||||
unmatchedLocation = true
|
||||
continue
|
||||
}
|
||||
|
||||
// All provided rules met.
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the location restriction is set and the "google.compute_engine.zone" claim is not present in the IDToken,
|
||||
// return a more specific error message.
|
||||
if unmatchedLocation && compute.Zone == "" {
|
||||
return trace.CompareFailed("id token %q claim is empty and didn't match the %q. "+
|
||||
"Services running outside of GCE VM instances are incompatible with %q restriction.", "google.compute_engine.zone", "locations", "location")
|
||||
}
|
||||
return trace.AccessDenied("id token claims did not match any allow rules")
|
||||
}
|
||||
|
||||
type gcpLocation struct {
|
||||
globalLocation string
|
||||
region string
|
||||
zone string
|
||||
}
|
||||
|
||||
func parseGCPLocation(location string) (*gcpLocation, error) {
|
||||
parts := strings.Split(location, "-")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return nil, trace.BadParameter("location %q is not a valid GCP region or zone", location)
|
||||
}
|
||||
globalLocation, region := parts[0], parts[1]
|
||||
var zone string
|
||||
if len(parts) == 3 {
|
||||
zone = parts[2]
|
||||
}
|
||||
return &gcpLocation{
|
||||
globalLocation: globalLocation,
|
||||
region: region,
|
||||
zone: zone,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isGCPZoneInLocation checks if a zone belongs to a location, which can be
|
||||
// either a zone or region.
|
||||
func isGCPZoneInLocation(rawLocation, rawZone string) bool {
|
||||
location, err := parseGCPLocation(rawLocation)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
zone, err := parseGCPLocation(rawZone)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Make sure zone is, in fact, a zone.
|
||||
if zone.zone == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if location.globalLocation != zone.globalLocation || location.region != zone.region {
|
||||
return false
|
||||
}
|
||||
return location.zone == "" || location.zone == zone.zone
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 gcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsGCPZoneInLocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
passingCases := []struct {
|
||||
name string
|
||||
location string
|
||||
zone string
|
||||
}{
|
||||
{
|
||||
name: "matching zone",
|
||||
location: "us-west1-b",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "matching region",
|
||||
location: "us-west1",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
}
|
||||
for _, tc := range passingCases {
|
||||
t.Run("accept "+tc.name, func(t *testing.T) {
|
||||
require.True(t, isGCPZoneInLocation(tc.location, tc.zone))
|
||||
})
|
||||
}
|
||||
|
||||
failingCases := []struct {
|
||||
name string
|
||||
location string
|
||||
zone string
|
||||
}{
|
||||
{
|
||||
name: "non-matching zone",
|
||||
location: "europe-southwest1-b",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "non-matching region",
|
||||
location: "europe-southwest1",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "malformed location",
|
||||
location: "us",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "similar but non-matching region",
|
||||
location: "europe-west1",
|
||||
zone: "europe-west12-a",
|
||||
},
|
||||
{
|
||||
name: "empty zone",
|
||||
location: "us-west1",
|
||||
zone: "",
|
||||
},
|
||||
{
|
||||
name: "empty location",
|
||||
location: "",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "invalid zone",
|
||||
location: "us-west1",
|
||||
zone: "us-west1",
|
||||
},
|
||||
}
|
||||
for _, tc := range failingCases {
|
||||
t.Run("reject "+tc.name, func(t *testing.T) {
|
||||
require.False(t, isGCPZoneInLocation(tc.location, tc.zone))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package auth_test
|
||||
package join_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -24,13 +24,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/lib/auth"
|
||||
"github.com/gravitational/teleport/lib/auth/authtest"
|
||||
"github.com/gravitational/teleport/lib/auth/state"
|
||||
"github.com/gravitational/teleport/lib/auth/testauthority"
|
||||
"github.com/gravitational/teleport/lib/gcp"
|
||||
"github.com/gravitational/teleport/lib/join/gcp"
|
||||
"github.com/gravitational/teleport/lib/join/joinclient"
|
||||
)
|
||||
|
||||
type mockGCPTokenValidator struct {
|
||||
@@ -45,7 +47,7 @@ func (m *mockGCPTokenValidator) Validate(_ context.Context, token string) (*gcp.
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
|
||||
func TestJoinGCP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validIDToken := "test.fake.jwt"
|
||||
@@ -64,18 +66,19 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
var withTokenValidator auth.ServerOption = func(server *auth.Server) error {
|
||||
server.SetGCPIDTokenValidator(idTokenValidator)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
p, err := newTestPack(ctx, testPackOptions{
|
||||
DataDir: t.TempDir(),
|
||||
MutateAuth: withTokenValidator,
|
||||
|
||||
authServer, err := authtest.NewTestServer(authtest.ServerConfig{
|
||||
Auth: authtest.AuthServerConfig{
|
||||
Dir: t.TempDir(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
auth := p.a
|
||||
t.Cleanup(func() { assert.NoError(t, authServer.Shutdown(t.Context())) })
|
||||
auth := authServer.Auth()
|
||||
|
||||
authServer.Auth().SetGCPIDTokenValidator(idTokenValidator)
|
||||
|
||||
// helper for creating RegisterUsingTokenRequest
|
||||
sshPrivateKey, sshPublicKey, err := testauthority.New().GenerateKeyPair()
|
||||
@@ -129,7 +132,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
|
||||
assertError: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "multiple allow rules",
|
||||
name: "multiple-allow-rules",
|
||||
tokenSpec: types.ProvisionTokenSpecV2{
|
||||
JoinMethod: types.JoinMethodGCP,
|
||||
Roles: []types.SystemRole{types.RoleNode},
|
||||
@@ -146,7 +149,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
|
||||
assertError: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "match region to zone",
|
||||
name: "match-region-to-zone",
|
||||
tokenSpec: types.ProvisionTokenSpecV2{
|
||||
JoinMethod: types.JoinMethodGCP,
|
||||
Roles: []types.SystemRole{types.RoleNode},
|
||||
@@ -162,7 +165,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
|
||||
assertError: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "incorrect project id",
|
||||
name: "incorrect-project-id",
|
||||
tokenSpec: types.ProvisionTokenSpecV2{
|
||||
JoinMethod: types.JoinMethodGCP,
|
||||
Roles: []types.SystemRole{types.RoleNode},
|
||||
@@ -178,7 +181,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
|
||||
assertError: allowRulesNotMatched,
|
||||
},
|
||||
{
|
||||
name: "incorrect location",
|
||||
name: "incorrect-location",
|
||||
tokenSpec: types.ProvisionTokenSpecV2{
|
||||
JoinMethod: types.JoinMethodGCP,
|
||||
Roles: []types.SystemRole{types.RoleNode},
|
||||
@@ -194,7 +197,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
|
||||
assertError: allowRulesNotMatched,
|
||||
},
|
||||
{
|
||||
name: "incorrect service account",
|
||||
name: "incorrect-service-account",
|
||||
tokenSpec: types.ProvisionTokenSpecV2{
|
||||
JoinMethod: types.JoinMethodGCP,
|
||||
Roles: []types.SystemRole{types.RoleNode},
|
||||
@@ -219,80 +222,48 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
|
||||
require.NoError(t, auth.CreateToken(ctx, token))
|
||||
tc.request.Token = tc.name
|
||||
|
||||
_, err = auth.RegisterUsingToken(ctx, tc.request)
|
||||
tc.assertError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
nopClient, err := authServer.NewClient(authtest.TestNop())
|
||||
require.NoError(t, err)
|
||||
|
||||
func TestIsGCPZoneInLocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
passingCases := []struct {
|
||||
name string
|
||||
location string
|
||||
zone string
|
||||
}{
|
||||
{
|
||||
name: "matching zone",
|
||||
location: "us-west1-b",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "matching region",
|
||||
location: "us-west1",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
}
|
||||
for _, tc := range passingCases {
|
||||
t.Run("accept "+tc.name, func(t *testing.T) {
|
||||
require.True(t, auth.IsGCPZoneInLocation(tc.location, tc.zone))
|
||||
})
|
||||
}
|
||||
t.Run("legacy", func(t *testing.T) {
|
||||
_, err = auth.RegisterUsingToken(ctx, tc.request)
|
||||
tc.assertError(t, err)
|
||||
})
|
||||
|
||||
failingCases := []struct {
|
||||
name string
|
||||
location string
|
||||
zone string
|
||||
}{
|
||||
{
|
||||
name: "non-matching zone",
|
||||
location: "europe-southwest1-b",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "non-matching region",
|
||||
location: "europe-southwest1",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "malformed location",
|
||||
location: "us",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "similar but non-matching region",
|
||||
location: "europe-west1",
|
||||
zone: "europe-west12-a",
|
||||
},
|
||||
{
|
||||
name: "empty zone",
|
||||
location: "us-west1",
|
||||
zone: "",
|
||||
},
|
||||
{
|
||||
name: "empty location",
|
||||
location: "",
|
||||
zone: "us-west1-b",
|
||||
},
|
||||
{
|
||||
name: "invalid zone",
|
||||
location: "us-west1",
|
||||
zone: "us-west1",
|
||||
},
|
||||
}
|
||||
for _, tc := range failingCases {
|
||||
t.Run("reject "+tc.name, func(t *testing.T) {
|
||||
require.False(t, auth.IsGCPZoneInLocation(tc.location, tc.zone))
|
||||
t.Run("legacy joinclient", func(t *testing.T) {
|
||||
_, err := joinclient.LegacyJoin(t.Context(), joinclient.JoinParams{
|
||||
Token: tc.request.Token,
|
||||
JoinMethod: types.JoinMethodGCP,
|
||||
ID: state.IdentityID{
|
||||
Role: tc.request.Role,
|
||||
NodeName: "testnode",
|
||||
HostUUID: tc.request.HostID,
|
||||
},
|
||||
IDToken: tc.request.IDToken,
|
||||
AuthClient: nopClient,
|
||||
})
|
||||
tc.assertError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new joinclient", func(t *testing.T) {
|
||||
_, err := joinclient.Join(t.Context(), joinclient.JoinParams{
|
||||
Token: tc.request.Token,
|
||||
JoinMethod: types.JoinMethodGCP,
|
||||
ID: state.IdentityID{
|
||||
Role: types.RoleInstance, // RoleNode is not allowed
|
||||
NodeName: "testnode",
|
||||
},
|
||||
IDToken: tc.request.IDToken,
|
||||
AuthClient: nopClient,
|
||||
})
|
||||
tc.assertError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
authjoin "github.com/gravitational/teleport/lib/auth/join"
|
||||
proxyinsecureclient "github.com/gravitational/teleport/lib/client/proxy/insecure"
|
||||
"github.com/gravitational/teleport/lib/cloud/imds/gcp"
|
||||
"github.com/gravitational/teleport/lib/cryptosuites"
|
||||
"github.com/gravitational/teleport/lib/join/bitbucket"
|
||||
"github.com/gravitational/teleport/lib/join/env0"
|
||||
@@ -202,6 +203,7 @@ func joinWithClient(ctx context.Context, params JoinParams, client *joinv1.Clien
|
||||
types.JoinMethodBoundKeypair,
|
||||
types.JoinMethodEC2,
|
||||
types.JoinMethodEnv0,
|
||||
types.JoinMethodGCP,
|
||||
types.JoinMethodGitHub,
|
||||
types.JoinMethodGitLab,
|
||||
types.JoinMethodIAM,
|
||||
@@ -319,6 +321,15 @@ func joinWithMethod(
|
||||
return oidcJoin(stream, joinParams, clientParams)
|
||||
case types.JoinMethodOracle:
|
||||
return oracleJoin(ctx, stream, joinParams, clientParams)
|
||||
case types.JoinMethodGCP:
|
||||
if joinParams.IDToken == "" {
|
||||
joinParams.IDToken, err = gcp.GetIDToken(ctx)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
return oidcJoin(stream, joinParams, clientParams)
|
||||
case types.JoinMethodGitHub:
|
||||
if joinParams.IDToken == "" {
|
||||
joinParams.IDToken, err = githubactions.NewIDTokenSource().GetIDToken(ctx)
|
||||
|
||||
@@ -47,6 +47,7 @@ import (
|
||||
"github.com/gravitational/teleport/lib/events"
|
||||
"github.com/gravitational/teleport/lib/join/bitbucket"
|
||||
"github.com/gravitational/teleport/lib/join/ec2join"
|
||||
"github.com/gravitational/teleport/lib/join/gcp"
|
||||
"github.com/gravitational/teleport/lib/join/githubactions"
|
||||
"github.com/gravitational/teleport/lib/join/gitlab"
|
||||
joinauthz "github.com/gravitational/teleport/lib/join/internal/authz"
|
||||
@@ -85,6 +86,7 @@ type AuthService interface {
|
||||
GetBitbucketIDTokenValidator() bitbucket.Validator
|
||||
GetEC2ClientForEC2JoinMethod() ec2join.EC2Client
|
||||
GetEnv0IDTokenValidator() Env0TokenValidator
|
||||
GetGCPIDTokenValidator() gcp.Validator
|
||||
GetGHAIDTokenValidator() githubactions.GithubIDTokenValidator
|
||||
GetGHAIDTokenJWKSValidator() githubactions.GithubIDTokenJWKSValidator
|
||||
GetGitlabIDTokenValidator() gitlab.Validator
|
||||
@@ -294,6 +296,8 @@ func (s *Server) handleJoinMethod(
|
||||
return s.handleOIDCJoin(stream, authCtx, clientInit, token, s.validateEnv0Token)
|
||||
case types.JoinMethodOracle:
|
||||
return s.handleOracleJoin(stream, authCtx, clientInit, token)
|
||||
case types.JoinMethodGCP:
|
||||
return s.handleOIDCJoin(stream, authCtx, clientInit, token, s.validateGCPToken)
|
||||
case types.JoinMethodGitHub:
|
||||
return s.handleOIDCJoin(stream, authCtx, clientInit, token, s.validateGithubToken)
|
||||
case types.JoinMethodGitLab:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 join
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
|
||||
workloadidentityv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
|
||||
"github.com/gravitational/teleport/lib/join/gcp"
|
||||
"github.com/gravitational/teleport/lib/join/provision"
|
||||
)
|
||||
|
||||
// validateGCPToken performs validation and allow rule verification against
|
||||
// a GCP OIDC token.
|
||||
func (a *Server) validateGCPToken(
|
||||
ctx context.Context,
|
||||
pt provision.Token,
|
||||
idToken []byte,
|
||||
) (any, *workloadidentityv1.JoinAttrs, error) {
|
||||
claims, err := gcp.CheckIDToken(ctx, &gcp.CheckIDTokenParams{
|
||||
ProvisionToken: pt,
|
||||
IDToken: idToken,
|
||||
Validator: a.cfg.AuthService.GetGCPIDTokenValidator(),
|
||||
})
|
||||
|
||||
// If possible, attach claims and workload ID attrs regardless of the error
|
||||
// return. If the token fails to validate, these claims will ensure audit
|
||||
// events remain useful.
|
||||
var workloadIDAttrs *workloadidentityv1.JoinAttrs
|
||||
if claims != nil {
|
||||
workloadIDAttrs = &workloadidentityv1.JoinAttrs{
|
||||
Gcp: claims.JoinAttrs(),
|
||||
}
|
||||
}
|
||||
|
||||
return claims, workloadIDAttrs, trace.Wrap(err)
|
||||
}
|
||||
Reference in New Issue
Block a user