Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/cloud-api-adaptor/pkg/adaptor/cloud/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,26 @@ func (s *cloudService) CreateVM(ctx context.Context, req *pb.CreateVMRequest) (r
// Ignore errors getting secrets to match K8S behavior
logger.Printf("error reading image pull secrets: %v", err)
}

// Some providers can mint registry credentials from their own cloud
// identity (e.g. GCP via Workload Identity). When the provider
// implements ImagePullAuthAugmenter, merge whatever it returns into
// the operator-supplied auth.json so podvm pulls work without an
// imagePullSecret. Failures are non-fatal: fall back to whatever
// imagePullSecrets-derived auth (or none) we already have.
if augmenter, ok := s.provider.(provider.ImagePullAuthAugmenter); ok {
extraAuth, augErr := augmenter.AugmentImagePullAuth(ctx)
if augErr != nil {
logger.Printf("warning: image-pull auth augmentation failed: %v", augErr)
} else if extraAuth != nil {
if merged, mergeErr := mergeDockerAuths(authJSON, extraAuth); mergeErr != nil {
logger.Printf("warning: merging provider image-pull auth failed: %v", mergeErr)
} else {
authJSON = merged
}
}
}

if authJSON != nil {
logger.Printf("successfully retrieved pod image pull secrets for %s/%s", namespace, pod)
if len(authJSON) > cloudinit.DefaultAuthfileLimit {
Expand Down Expand Up @@ -452,3 +472,38 @@ func (s *cloudService) StopVM(ctx context.Context, req *pb.StopVMRequest) (*pb.S

return &pb.StopVMResponse{}, nil
}

// mergeDockerAuths combines two docker-config-json byte payloads
// ({"auths": {host: {...}}}) into one document. Entries from right take
// precedence on host-key conflicts -- the right-hand side is the
// cloud-provider-derived auth, the freshest material, which should win
// over any stale imagePullSecret for the same host. Either input may be
// nil/empty.
//
// Per-host entries are preserved verbatim as raw JSON, so fields this
// code does not model (identitytoken, registrytoken, etc.) survive the
// merge untouched.
func mergeDockerAuths(left, right []byte) ([]byte, error) {
type doc struct {
Auths map[string]json.RawMessage `json:"auths"`
}

merged := doc{Auths: map[string]json.RawMessage{}}
for _, src := range [][]byte{left, right} {
if len(src) == 0 {
continue
}
var d doc
if err := json.Unmarshal(src, &d); err != nil {
return nil, fmt.Errorf("unmarshal auths: %w", err)
}
for host, raw := range d.Auths {
merged.Auths[host] = raw
}
}

if len(merged.Auths) == 0 {
return nil, nil
}
return json.Marshal(merged)
}
149 changes: 149 additions & 0 deletions src/cloud-providers/azure/imagepullauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// (C) Copyright Confidential Containers Contributors
// SPDX-License-Identifier: Apache-2.0

package azure

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
)

// acrPullAuthScope is the AAD scope requested for the token exchanged
// with ACR. ACR is the resource being authenticated to; the exchanged
// refresh token's actual authority is bounded by the AcrPull role
// assignment held by the identity it was minted for (see
// Config.PullIdentity), not by this scope.
const acrPullAuthScope = "https://containerregistry.azure.net/.default"

// acrRefreshTokenUsername is the fixed sentinel docker/containerd (and
// `az acr login`) use in a docker-config-json "auth" field to signal
// "this password is an ACR refresh token, not a real username/password
// pair" -- the registry's own bearer-challenge flow exchanges it for a
// repository-scoped access token on each pull, so CAA only ever has to
// hand over the refresh token, not a pre-scoped access token.
const acrRefreshTokenUsername = "00000000-0000-0000-0000-000000000000"

// dockerConfigJSON mirrors the on-wire shape of a Kubernetes
// dockerconfigjson payload -- {"auths": {"<host>": {"auth":
// "<base64(user:pass)>"}}} -- so the bytes returned here merge 1:1 with
// the imagePullSecrets-derived bytes CAA already builds.
type dockerConfigJSON struct {
Auths map[string]dockerAuthEntry `json:"auths"`
}

type dockerAuthEntry struct {
Auth string `json:"auth"`
}

// AugmentImagePullAuth mints an ACR refresh token from CAA's cloud
// identity (optionally impersonating Config.PullIdentity) and emits a
// docker-config-json document that authenticates pulls from
// Config.PullRegistry as that identity. Implements
// provider.ImagePullAuthAugmenter.
//
// Refresh tokens live ~3h. They are shipped into the podvm at create
// time and consumed by CDH at container-create time, typically seconds
// after VM boot -- well inside the TTL. kata-remote does not re-pull on
// container restart, so a single token per VM is sufficient.
func (p *azureProvider) AugmentImagePullAuth(ctx context.Context) ([]byte, error) {
host := p.serviceConfig.PullRegistry
if host == "" {
// Feature disabled; no error so the caller treats "unsupported"
// and "configured off" identically.
return nil, nil
}

refreshToken, err := p.mintACRRefreshToken(ctx, host)
if err != nil {
return nil, err
}

auth := base64.StdEncoding.EncodeToString([]byte(acrRefreshTokenUsername + ":" + refreshToken))

return json.Marshal(dockerConfigJSON{
Auths: map[string]dockerAuthEntry{host: {Auth: auth}},
})
}

// pullTokenCredential returns a credential that requests an AAD token
// for PullIdentity's client ID specifically -- Azure Workload Identity
// has no impersonation primitive, so this only works when PullIdentity's
// managed identity has its own federated credential trusting CAA's K8s
// ServiceAccount (same OIDC issuer + subject as CAA's own identity).
//
// ConfigVerifier requires PullIdentity and PullRegistry to be set
// together, so there is no "reuse CAA's own credential" fallback here.
func (p *azureProvider) pullTokenCredential() (azcore.TokenCredential, error) {
cred, err := azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{
ClientID: p.serviceConfig.PullIdentity,
})
if err != nil {
return nil, fmt.Errorf("workload identity credential for %s: %w", p.serviceConfig.PullIdentity, err)
}
return cred, nil
}

// mintACRRefreshToken obtains an AAD access token via pullTokenCredential
// and exchanges it for a registry-scoped ACR refresh token by calling
// the registry's own OAuth2 exchange endpoint -- the same exchange `az
// acr login` and docker-credential-acr-env perform.
func (p *azureProvider) mintACRRefreshToken(ctx context.Context, host string) (string, error) {
cred, err := p.pullTokenCredential()
if err != nil {
return "", fmt.Errorf("building image-pull credential: %w", err)
}

tok, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{acrPullAuthScope}})
if err != nil {
return "", fmt.Errorf("mint AAD token for image-pull: %w", err)
}

form := url.Values{
"grant_type": {"access_token"},
"service": {host},
"access_token": {tok.Token},
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+host+"/oauth2/exchange", strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("building ACR exchange request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("calling ACR exchange endpoint at %s: %w", host, err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading ACR exchange response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ACR exchange at %s returned %s: %s", host, resp.Status, body)
}

var exchangeResp struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.Unmarshal(body, &exchangeResp); err != nil {
return "", fmt.Errorf("parsing ACR exchange response: %w", err)
}
if exchangeResp.RefreshToken == "" {
return "", fmt.Errorf("ACR exchange at %s returned no refresh_token", host)
}

return exchangeResp.RefreshToken, nil
}
2 changes: 2 additions & 0 deletions src/cloud-providers/azure/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ func (*Manager) ParseCmd(flags *flag.FlagSet) {
reg.BoolWithEnv(&azurecfg.EnableSecureBoot, "enable-secure-boot", false, "ENABLE_SECURE_BOOT", "Enable secure boot for the VMs")
reg.BoolWithEnv(&azurecfg.UsePublicIP, "use-public-ip", false, "USE_PUBLIC_IP", "Assign public IP to the PoD VM and use to connect to kata-agent")
reg.IntWithEnv(&azurecfg.RootVolumeSize, "root-volume-size", 0, "ROOT_VOLUME_SIZE", "Root volume size in GB. Default is 0, which implies the default image disk size")
reg.StringWithEnv(&azurecfg.PullRegistry, "pull-registry", "", "AZURE_PULL_REGISTRY", "Registry hostname (e.g. myregistry.azurecr.io) to authenticate Pod VM image pulls against, using a short-lived ACR refresh token minted from CAA's cloud identity. Empty disables the feature.")
reg.StringWithEnv(&azurecfg.PullIdentity, "pull-identity", "", "AZURE_PULL_IDENTITY", "Optional client ID of a user-assigned managed identity to request the image-pull token for; should hold only AcrPull on AZURE_PULL_REGISTRY, and must have its own federated credential trusting CAA's K8s ServiceAccount. Empty mints from CAA's own workload identity.")

// Custom flag types (comma-separated lists)
reg.CustomTypeWithEnv(&azurecfg.InstanceSizes, "instance-sizes", "", "AZURE_INSTANCE_SIZES", "Instance sizes to be used for the Pod VMs, comma separated")
Expand Down
30 changes: 30 additions & 0 deletions src/cloud-providers/azure/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,36 @@ func (p *azureProvider) ConfigVerifier() error {
return fmt.Errorf("SSH key is invalid: %s", err)
}
}

// AZURE_PULL_REGISTRY and AZURE_PULL_IDENTITY must be set together.
// Leaving both unset is untouched by this check -- that's the
// feature being off, same as before it existed.
//
// AZURE_PULL_IDENTITY without AZURE_PULL_REGISTRY is a no-op that
// silently disables the feature -- almost certainly a
// misconfiguration, so fail fast rather than ship podvms with no
// registry auth.
//
// AZURE_PULL_REGISTRY without AZURE_PULL_IDENTITY used to silently
// fall back to minting the pull token from CAA's own identity --
// which is usually far more privileged than "read this registry"
// (see pullTokenCredential). That's a real footgun: enabling the
// feature without also naming a least-privilege identity embeds
// CAA's full authority into every podvm's auth.json with no
// warning. Fail fast instead of shipping that silently.
pullRegistrySet := p.serviceConfig.PullRegistry != ""
pullIdentitySet := p.serviceConfig.PullIdentity != ""
if pullRegistrySet != pullIdentitySet {
if pullIdentitySet {
return fmt.Errorf("AZURE_PULL_IDENTITY is set but AZURE_PULL_REGISTRY is empty; set the registry host or unset the impersonation target")
}
return fmt.Errorf("AZURE_PULL_REGISTRY is set but AZURE_PULL_IDENTITY is empty; set a least-privilege managed identity (holding only AcrPull on AZURE_PULL_REGISTRY, with its own federated credential trusting CAA's K8s ServiceAccount) rather than embedding CAA's own identity into every podvm, or unset AZURE_PULL_REGISTRY")
}
// PullRegistry must be a bare registry host (used as a docker
// auth.json key), not a full image reference.
if h := p.serviceConfig.PullRegistry; h != "" && strings.ContainsAny(h, "/@") {
return fmt.Errorf("AZURE_PULL_REGISTRY must be a registry hostname (e.g. myregistry.azurecr.io), got %q", h)
}
return nil
}

Expand Down
31 changes: 31 additions & 0 deletions src/cloud-providers/azure/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,37 @@ type Config struct {
EnableSecureBoot bool
UsePublicIP bool
RootVolumeSize int

// PullRegistry is the single registry hostname (e.g.
// "myregistry.azurecr.io") that podvm image pulls are authenticated
// against using a short-lived ACR refresh token minted from CAA's
// cloud identity. Empty disables the image-pull-auth feature.
//
// One refresh token authenticates every repository under that ACR
// instance (the docker registry-v2 bearer challenge scopes it down
// per-pull), so a single host key is sufficient when all
// ACR-hosted images a pod pulls live under one registry. Non-ACR
// registries continue to use the operator's imagePullSecrets.
PullRegistry string

// PullIdentity optionally names the client ID of a user-assigned
// managed identity to request the AAD token for, instead of CAA's
// own identity. The recommended configuration is a dedicated,
// least-privilege identity holding only AcrPull on PullRegistry:
// the minted refresh token is embedded in the guest's auth.json, so
// it should carry no more authority than reading the registry.
//
// Unlike GCP's service-account impersonation, Azure Workload
// Identity has no "impersonate any identity you hold a role on"
// primitive: PullIdentity's managed identity must have its own
// federated credential trusting CAA's K8s ServiceAccount (the same
// OIDC issuer + subject CAA's own identity is federated to).
//
// If empty, the token is minted directly from CAA's own workload
// identity. That is only safe when CAA's identity is itself
// low-privilege; if it can manage VMs, do NOT use this path, as it
// embeds a token with CAA's full authority into every podvm.
PullIdentity string
}

func (c Config) Redact() Config {
Expand Down
96 changes: 96 additions & 0 deletions src/cloud-providers/gcp/imagepullauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// (C) Copyright Confidential Containers Contributors
// SPDX-License-Identifier: Apache-2.0

package gcp

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"

"golang.org/x/oauth2"
"google.golang.org/api/impersonate"
"google.golang.org/api/option"
)

// pullAuthScope is the OAuth scope requested for the image-pull token.
// Artifact Registry / gcr.io accept cloud-platform; the token's actual
// authority is bounded by the IAM roles of the identity it is minted
// for (see Config.PullImpersonate), not by the scope.
const pullAuthScope = "https://www.googleapis.com/auth/cloud-platform"

// dockerConfigJSON mirrors the on-wire shape of a Kubernetes
// dockerconfigjson payload -- {"auths": {"<host>": {"auth":
// "<base64(user:pass)>"}}} -- so the bytes returned here merge 1:1 with
// the imagePullSecrets-derived bytes CAA already builds.
type dockerConfigJSON struct {
Auths map[string]dockerAuthEntry `json:"auths"`
}

type dockerAuthEntry struct {
Auth string `json:"auth"`
}

// AugmentImagePullAuth mints a short-lived OAuth token by impersonating
// Config.PullImpersonate and emits a docker-config-json document that
// authenticates pulls from Config.PullRegistry as that identity.
// Implements provider.ImagePullAuthAugmenter.
//
// ConfigVerifier requires PullImpersonate and PullRegistry to be set
// together, so there is no "mint from CAA's own identity" fallback
// here -- see pullTokenSource.
//
// Tokens live ~1h. They are shipped into the podvm at create time and
// consumed by CDH at container-create time, typically seconds after VM
// boot -- well inside the TTL. kata-remote does not re-pull on container
// restart, so a single token per VM is sufficient.
func (p *gcpProvider) AugmentImagePullAuth(ctx context.Context) ([]byte, error) {
host := p.serviceConfig.PullRegistry
if host == "" {
// Feature disabled; no error so the caller treats "unsupported"
// and "configured off" identically.
return nil, nil
}

ts, err := p.pullTokenSource(ctx)
if err != nil {
return nil, err
}

tok, err := ts.Token()
if err != nil {
return nil, fmt.Errorf("mint image-pull token: %w", err)
}

auth := base64.StdEncoding.EncodeToString([]byte("oauth2accesstoken:" + tok.AccessToken))

return json.Marshal(dockerConfigJSON{
Auths: map[string]dockerAuthEntry{host: {Auth: auth}},
})
}

// pullTokenSource returns a token source impersonating Config.PullImpersonate
// -- a least-privilege pull SA. When GCP_CREDENTIALS is configured it is
// used as the base credential to impersonate from, matching how
// NewProvider builds the compute client; otherwise the base credential
// is Application Default Credentials (CAA's own identity), which
// impersonate.CredentialsTokenSource uses only to perform the
// impersonation call itself -- the returned token carries
// PullImpersonate's authority, not the base credential's.
func (p *gcpProvider) pullTokenSource(ctx context.Context) (oauth2.TokenSource, error) {
var opts []option.ClientOption
if p.serviceConfig.GcpCredentials != "" {
opts = append(opts, option.WithCredentialsJSON([]byte(p.serviceConfig.GcpCredentials)))
}

sa := p.serviceConfig.PullImpersonate
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
TargetPrincipal: sa,
Scopes: []string{pullAuthScope},
}, opts...)
if err != nil {
return nil, fmt.Errorf("impersonate %s for image-pull token: %w", sa, err)
}
return ts, nil
}
Loading
Loading