From 520efb07bbf4986a2940d6781a65374c74696837 Mon Sep 17 00:00:00 2001 From: Stefan Sauer Date: Thu, 16 Jan 2025 17:40:17 +0100 Subject: [PATCH] Add support for intermediate delegates. (#477) This allows for setups where actual permisisons are granted on the delegate and the robot-sa only needs the permisisons to impersonate the delegate. See b/367635510 --- src/go/cmd/token-vendor/README.md | 1 + src/go/cmd/token-vendor/app/tokenvendor.go | 29 +++++++++---------- src/go/cmd/token-vendor/repository/k8s/k8s.go | 5 +++- .../token-vendor/repository/memory/memory.go | 2 +- .../cmd/token-vendor/repository/repository.go | 2 ++ src/go/cmd/token-vendor/tokensource/gcp.go | 18 ++++++++++-- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/go/cmd/token-vendor/README.md b/src/go/cmd/token-vendor/README.md index fbb6181d..0fa1c770 100644 --- a/src/go/cmd/token-vendor/README.md +++ b/src/go/cmd/token-vendor/README.md @@ -66,6 +66,7 @@ Configure optional properties of the on-prem robot registration. * device-id: unique device name (by default robot-) * Body: json { service-account: str, defaults to robot-service@.iam.gserviceaccount.com" + service-account-delegate: str, optional intermediate delegate } * Response: only http status code diff --git a/src/go/cmd/token-vendor/app/tokenvendor.go b/src/go/cmd/token-vendor/app/tokenvendor.go index d5edbd00..e6f1a478 100644 --- a/src/go/cmd/token-vendor/app/tokenvendor.go +++ b/src/go/cmd/token-vendor/app/tokenvendor.go @@ -92,51 +92,50 @@ func (tv *TokenVendor) GetOAuth2Token(ctx context.Context, jwtk string) (*tokens return r, err } -func (tv *TokenVendor) ValidateJWT(ctx context.Context, jwtk string) (string, string, error) { +func (tv *TokenVendor) ValidateJWT(ctx context.Context, jwtk string) (string, *repository.Key, error) { p, err := jwt.PayloadUnsafe(jwtk) if err != nil { - return "", "", errors.Wrap(err, "failed to extract JWT payload") + return "", nil, errors.Wrap(err, "failed to extract JWT payload") } exp := time.Unix(p.Exp, 0) if exp.Before(time.Now()) { - return "", "", fmt.Errorf("JWT has expired %v, %v ago (iss: %q)", + return "", nil, fmt.Errorf("JWT has expired %v, %v ago (iss: %q)", exp, time.Since(exp), p.Iss) } if err := acceptedAudience(p.Aud, tv.accAud); err != nil { - return "", "", errors.Wrapf(err, "validation of JWT audience failed (iss: %q)", p.Iss) + return "", nil, errors.Wrapf(err, "validation of JWT audience failed (iss: %q)", p.Iss) } if !IsValidDeviceID(p.Iss) { - return "", "", fmt.Errorf("missing or invalid device identifier (`iss`: %q)", p.Iss) + return "", nil, fmt.Errorf("missing or invalid device identifier (`iss`: %q)", p.Iss) } deviceID := p.Iss k, err := tv.repo.LookupKey(ctx, deviceID) if err != nil { - return "", "", errors.Wrapf(err, "failed to retrieve public key for device %q", deviceID) + return "", nil, errors.Wrapf(err, "failed to retrieve public key for device %q", deviceID) } if k.PublicKey == "" { - return "", "", errors.Errorf("no public key found for device %q", deviceID) + return "", nil, errors.Errorf("no public key found for device %q", deviceID) } err = jwt.VerifySignature(jwtk, k.PublicKey) if err != nil { - return "", "", errors.Wrapf(err, "failed to verify signature for device %q", deviceID) + return "", nil, errors.Wrapf(err, "failed to verify signature for device %q", deviceID) } - - return deviceID, k.SAName, nil + return deviceID, k, nil } func (tv *TokenVendor) getOAuth2Token(ctx context.Context, jwtk string) (*tokensource.TokenResponse, error) { - deviceID, sa, err := tv.ValidateJWT(ctx, jwtk) + deviceID, k, err := tv.ValidateJWT(ctx, jwtk) if err != nil { return nil, err } - if sa == "" { - sa = tv.defaultSAName + if k.SAName == "" { + k.SAName = tv.defaultSAName } - cloudToken, err := tv.ts.Token(ctx, sa) + cloudToken, err := tv.ts.Token(ctx, k.SAName, k.SADelegateName) if err != nil { return nil, errors.Wrapf(err, "failed to retrieve a cloud token for device %q", deviceID) } - slog.Info("Handing out cloud token", slog.String("DeviceID", deviceID), slog.String("ServiceAccount", sa)) + slog.Info("Handing out cloud token", slog.String("DeviceID", deviceID), slog.String("ServiceAccount", k.SAName)) return cloudToken, nil } diff --git a/src/go/cmd/token-vendor/repository/k8s/k8s.go b/src/go/cmd/token-vendor/repository/k8s/k8s.go index 3062ca3b..d967206d 100644 --- a/src/go/cmd/token-vendor/repository/k8s/k8s.go +++ b/src/go/cmd/token-vendor/repository/k8s/k8s.go @@ -47,6 +47,8 @@ const ( pubKey = "pubKey" // Configmap key for the public key // Configmap annotation specifies the service account to use (optional) serviceAccountAnnotation = "cloudrobotics.com/gcp-service-account" + // Configmap annotation specifies the intermediate service account delegate to use (optional) + serviceAccountDelegateAnnotation = "cloudrobotics.com/gcp-service-account-delegate" ) // ListAllDeviceIDs returns a slice of all device identifiers found in the namespace. @@ -82,7 +84,8 @@ func (k *K8sRepository) LookupKey(ctx context.Context, deviceID string) (*reposi return nil, fmt.Errorf("configmap %q/%q does not contain key %q", k.ns, deviceID, pubKey) } sa, _ := cm.ObjectMeta.Annotations[serviceAccountAnnotation] - return &repository.Key{key, sa}, nil + saDelegate, _ := cm.ObjectMeta.Annotations[serviceAccountDelegateAnnotation] + return &repository.Key{key, sa, saDelegate}, nil } // PublishKey sets or updates a public key for a given device identifier. diff --git a/src/go/cmd/token-vendor/repository/memory/memory.go b/src/go/cmd/token-vendor/repository/memory/memory.go index bcdb4d08..3f6b2df6 100644 --- a/src/go/cmd/token-vendor/repository/memory/memory.go +++ b/src/go/cmd/token-vendor/repository/memory/memory.go @@ -44,5 +44,5 @@ func (m *MemoryRepository) LookupKey(ctx context.Context, deviceID string) (*rep if !found { return nil, nil } - return &repository.Key{k, ""}, nil + return &repository.Key{k, "", ""}, nil } diff --git a/src/go/cmd/token-vendor/repository/repository.go b/src/go/cmd/token-vendor/repository/repository.go index a580d168..dac1b022 100644 --- a/src/go/cmd/token-vendor/repository/repository.go +++ b/src/go/cmd/token-vendor/repository/repository.go @@ -25,6 +25,8 @@ type Key struct { PublicKey string // SAName is the optional GCP IAM service-account that has been associated. SAName string + // SADelegateName is the optional GCP IAM service-account to act as an intermediate delegate + SADelegateName string } // PubKeyRepository defines the api for the pub key stores diff --git a/src/go/cmd/token-vendor/tokensource/gcp.go b/src/go/cmd/token-vendor/tokensource/gcp.go index 6f6b929b..35b3d38f 100644 --- a/src/go/cmd/token-vendor/tokensource/gcp.go +++ b/src/go/cmd/token-vendor/tokensource/gcp.go @@ -25,6 +25,10 @@ type TokenResponse struct { TokenType string `json:"token_type"` } +const ( + saPrefix = "projects/-/serviceAccounts/" +) + // NewGCPTokenSource creates a token source for GCP access tokens. // // `client` parameter is optional. If you supply your own client, you have to make @@ -43,12 +47,20 @@ func NewGCPTokenSource(ctx context.Context, client *http.Client, scopes []string // Token returns an access token for the configured service account and scopes. // // API: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken -func (g *GCPTokenSource) Token(ctx context.Context, saName string) (*TokenResponse, error) { - req := iam.GenerateAccessTokenRequest{Scope: g.scopes} +func (g *GCPTokenSource) Token(ctx context.Context, saName, saDelegateName string) (*TokenResponse, error) { if saName == "" { return nil, fmt.Errorf("saName must not be empty") } - resource := "projects/-/serviceAccounts/" + saName + + var delegates []string + if saDelegateName != "" { + delegates = append(delegates, saPrefix+saDelegateName) + } + req := iam.GenerateAccessTokenRequest{ + Scope: g.scopes, + Delegates: delegates, + } + resource := saPrefix + saName // We don't set a 'lifetime' on the request, so we get the default value (3600 sec = 1h). // This needs to be in sync with the min(cookie-expire,cookie-refresh) duration // configured on oauth2-proxy.