Skip to content

Commit

Permalink
Add support for intermediate delegates. (#477)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ensonic authored Jan 16, 2025
1 parent b3755af commit 520efb0
Show file tree
Hide file tree
Showing 6 changed files with 37 additions and 20 deletions.
1 change: 1 addition & 0 deletions src/go/cmd/token-vendor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Configure optional properties of the on-prem robot registration.
* device-id: unique device name (by default robot-<robot-id>)
* Body: json {
service-account: str, defaults to robot-service@<gcp-project>.iam.gserviceaccount.com"
service-account-delegate: str, optional intermediate delegate
}
* Response: only http status code

Expand Down
29 changes: 14 additions & 15 deletions src/go/cmd/token-vendor/app/tokenvendor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
5 changes: 4 additions & 1 deletion src/go/cmd/token-vendor/repository/k8s/k8s.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/go/cmd/token-vendor/repository/memory/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions src/go/cmd/token-vendor/repository/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions src/go/cmd/token-vendor/tokensource/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down

0 comments on commit 520efb0

Please sign in to comment.