Skip to content
Merged
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
2 changes: 1 addition & 1 deletion frontend/src/api/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export interface AccountListFilters {
}

export interface AccountCredentialsRequest {
credential_type: 'aws_access_keys' | 'azure_client_secret' | 'azure_wif_private_key' | 'gcp_service_account' | 'gcp_workload_identity_config';
credential_type: 'aws_access_keys' | 'azure_client_secret' | 'gcp_service_account' | 'gcp_workload_identity_config';
payload: Record<string, unknown>;
}

Expand Down
7 changes: 0 additions & 7 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1047,13 +1047,6 @@ <h3>Azure Authentication</h3>
<div id="account-azure-secret-fields" class="hidden">
<label>Client Secret: <input type="password" id="account-azure-client-secret" placeholder="Client secret value"></label>
</div>
<!-- workload_identity_federation mode -->
<div id="account-azure-wif-fields" class="hidden">
<label>RSA Private Key (PEM):
<textarea id="account-azure-wif-private-key" rows="6" placeholder="-----BEGIN RSA PRIVATE KEY-----&#10;...&#10;-----END RSA PRIVATE KEY-----"></textarea>
</label>
<small>Generate with: <code>openssl genrsa -out cudly-wif.key 2048</code>. Register the matching self-signed certificate with the Azure App Registration. Download the IaC from the Azure Settings &rarr; Federation Setup section to automate this.</small>
</div>
</div>

<!-- GCP-specific fields -->
Expand Down
25 changes: 10 additions & 15 deletions frontend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,6 @@ export function openAccountModal(provider: AccountProvider, account?: api.CloudA
setInputValue('account-azure-tenant-id', account?.azure_tenant_id ?? '');
setInputValue('account-azure-client-id', account?.azure_client_id ?? '');
setInputValue('account-azure-client-secret', '');
setInputValue('account-azure-wif-private-key', '');
updateAzureAuthModeFields(azureAuthMode);
} else if (provider === 'gcp') {
const gcpMode = account?.gcp_auth_mode ?? 'workload_identity_federation';
Expand Down Expand Up @@ -645,13 +644,15 @@ function updateAwsAuthModeFields(mode: string, bastionId?: string): void {
}

/**
* Show/hide Azure auth mode sub-fields
* Show/hide Azure auth mode sub-fields. Only client_secret mode has an
* input block; workload_identity_federation is credential-free (CUDly's
* OIDC issuer + the App Registration's federated-identity-credential
* handle authentication) and managed_identity is ambient, so neither
* needs a visible field block here.
*/
function updateAzureAuthModeFields(mode: string): void {
const secretFields = document.getElementById('account-azure-secret-fields');
const wifFields = document.getElementById('account-azure-wif-fields');
secretFields?.classList.toggle('hidden', mode !== 'client_secret');
wifFields?.classList.toggle('hidden', mode !== 'workload_identity_federation');
}

/**
Expand Down Expand Up @@ -753,17 +754,11 @@ async function saveAccountCredentialsIfFilled(accountId: string, provider: Accou
}
} else if (provider === 'azure') {
const azureMode = byId<HTMLSelectElement>('account-azure-auth-mode')?.value ?? 'client_secret';
if (azureMode === 'managed_identity') {
// No credential to store
} else if (azureMode === 'workload_identity_federation') {
const pem = byId<HTMLTextAreaElement>('account-azure-wif-private-key')?.value.trim();
if (pem) {
await api.saveAccountCredentials(accountId, {
credential_type: 'azure_wif_private_key',
payload: { private_key_pem: pem }
});
}
} else {
// managed_identity is ambient; workload_identity_federation is
// secret-free (CUDly's OIDC issuer + the Azure App Registration's
// federated-identity-credential handle authentication, no material
// stored in CUDly). Only client_secret accepts a stored credential.
if (azureMode === 'client_secret') {
const secret = byId<HTMLInputElement>('account-azure-client-secret')?.value ?? '';
if (secret) {
await api.saveAccountCredentials(accountId, {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
Expand Down
16 changes: 10 additions & 6 deletions internal/api/coverage_gaps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,18 +524,22 @@ func TestAmbientCredResult(t *testing.T) {

func TestCredTypeForAccount(t *testing.T) {
tests := []struct {
name string
acct *config.CloudAccount
expected string
}{
{&config.CloudAccount{Provider: "aws"}, "aws_access_keys"},
{&config.CloudAccount{Provider: "azure", AzureAuthMode: "service_principal"}, "azure_client_secret"},
{&config.CloudAccount{Provider: "azure", AzureAuthMode: "workload_identity_federation"}, "azure_wif_private_key"},
{&config.CloudAccount{Provider: "gcp", GCPAuthMode: "service_account"}, "gcp_service_account"},
{&config.CloudAccount{Provider: "gcp", GCPAuthMode: "workload_identity_federation"}, "gcp_workload_identity_config"},
{"aws", &config.CloudAccount{Provider: "aws"}, "aws_access_keys"},
{"azure_service_principal", &config.CloudAccount{Provider: "azure", AzureAuthMode: "service_principal"}, "azure_client_secret"},
// Azure WIF is secret-free (KMS-signed assertion, no stored
// credential), so credTypeForAccount returns "" — the caller
// handles the empty string as "no credential to check".
{"azure_wif", &config.CloudAccount{Provider: "azure", AzureAuthMode: "workload_identity_federation"}, ""},
{"gcp_service_account", &config.CloudAccount{Provider: "gcp", GCPAuthMode: "service_account"}, "gcp_service_account"},
{"gcp_wif", &config.CloudAccount{Provider: "gcp", GCPAuthMode: "workload_identity_federation"}, "gcp_workload_identity_config"},
}

for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, credTypeForAccount(tt.acct))
})
}
Expand Down
36 changes: 20 additions & 16 deletions internal/api/handler_accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ type AccountServiceOverrideRequest struct {
var validCredentialTypes = map[string]bool{
"aws_access_keys": true,
"azure_client_secret": true,
"azure_wif_private_key": true,
"gcp_service_account": true,
"gcp_workload_identity_config": true,
}
Expand Down Expand Up @@ -454,7 +453,7 @@ func (h *Handler) parseAndValidateCredentialsRequest(ctx context.Context, httpRe
return nil, nil, NewClientError(400, "invalid request body")
}
if !validCredentialTypes[req.CredentialType] {
return nil, nil, NewClientError(400, "credential_type must be one of: aws_access_keys, azure_client_secret, azure_wif_private_key, gcp_service_account, gcp_workload_identity_config")
return nil, nil, NewClientError(400, "credential_type must be one of: aws_access_keys, azure_client_secret, gcp_service_account, gcp_workload_identity_config")
}
if err := validateCredentialPayload(req.CredentialType, req.Payload); err != nil {
return nil, nil, err
Expand Down Expand Up @@ -694,11 +693,11 @@ type tokenResult struct {

// azureFederatedCredResult exercises the secret-free Azure federated
// credential path end-to-end for accounts in workload_identity_federation
// mode whose CUDly deployment has an OIDC signer + issuer configured and
// no legacy PEM stored. It mints a client_assertion JWT via the KMS
// signer, exchanges it at Azure AD's token endpoint, and reports the
// result. Returns (result, true) when this path applies; (_, false)
// means the caller should fall back to the presence check.
// mode whose CUDly deployment has an OIDC signer + issuer configured.
// It mints a client_assertion JWT via the KMS signer, exchanges it at
// Azure AD's token endpoint, and reports the result. Returns
// (result, true) when this path applies; (_, false) means the caller
// should fall back to the presence check (client_secret mode only).
func (h *Handler) azureFederatedCredResult(ctx context.Context, acct *config.CloudAccount) (AccountTestResult, bool) {
if acct.Provider != "azure" || acct.AzureAuthMode != "workload_identity_federation" {
return AccountTestResult{}, false
Expand All @@ -710,13 +709,6 @@ func (h *Handler) azureFederatedCredResult(ctx context.Context, acct *config.Clo
if issuer == "" {
return AccountTestResult{}, false
}
// If a legacy PEM is stored, let the presence-check path handle it
// so cert-based accounts keep reporting the same shape.
if h.credStore != nil {
if has, _ := h.credStore.HasCredential(ctx, acct.ID, credentials.CredTypeAzureWIF); has {
return AccountTestResult{}, false
}
}

cred, err := credentials.BuildAzureFederatedCredential(h.signer, issuer, acct.AzureTenantID, acct.AzureClientID)
if err != nil {
Expand Down Expand Up @@ -744,6 +736,15 @@ func (h *Handler) azureFederatedCredResult(ctx context.Context, acct *config.Clo
func (h *Handler) checkCredentialPresence(ctx context.Context, acct *config.CloudAccount) (AccountTestResult, error) {
if h.credStore != nil {
credType := credTypeForAccount(acct)
// Empty credType means this auth mode isn't backed by a stored
// credential — e.g. Azure workload_identity_federation on a
// deployment where the OIDC signer wasn't wired (so
// azureFederatedCredResult returned ok=false and we fell
// through to here). Report an operator-facing message instead
// of querying for "no credential stored".
if credType == "" {
return AccountTestResult{OK: false, Message: "this account's auth mode is not backed by a stored credential — check the deployment's OIDC issuer wiring"}, nil
}
has, err := h.credStore.HasCredential(ctx, acct.ID, credType)
if err != nil {
return AccountTestResult{}, fmt.Errorf("accounts: check credential: %w", err)
Expand All @@ -766,12 +767,15 @@ func (h *Handler) checkCredentialPresence(ctx context.Context, acct *config.Clou
}

// credTypeForAccount returns the expected credential_type for an account
// based on its provider and auth mode.
// based on its provider and auth mode. Returns "" for auth modes that
// aren't backed by a stored credential (e.g. Azure
// workload_identity_federation, which uses the deployment's OIDC signer
// at request time and stores nothing per-account).
func credTypeForAccount(acct *config.CloudAccount) string {
switch acct.Provider {
case "azure":
if acct.AzureAuthMode == "workload_identity_federation" {
return "azure_wif_private_key"
return ""
}
return "azure_client_secret"
case "gcp":
Expand Down
6 changes: 2 additions & 4 deletions internal/api/handler_federation.go
Original file line number Diff line number Diff line change
Expand Up @@ -647,11 +647,9 @@ func buildBundleReadme(data federationIaCData, target, source string) string {
sb.WriteString(" cd cloudformation && bash deploy-cfn.sh --region <region>\n\n")
sb.WriteString("After apply, set aws_auth_mode=workload_identity_federation and aws_role_arn in CUDly.\n")
case target == "azure":
sb.WriteString("Contents:\n terraform/ - Azure App Registration + cert WIF Terraform module\n")
sb.WriteString("Contents:\n terraform/ - Azure App Registration + federated-identity-credential Terraform module\n")
sb.WriteString(" terraform/*.auto.tfvars - Pre-filled variable values (auto-loaded by Terraform)\n\n")
sb.WriteString("Prerequisites:\n 1. Generate an RSA key and self-signed certificate (see tfvars comments).\n")
sb.WriteString(" 2. Paste the certificate PEM into the tfvars file.\n")
sb.WriteString(" 3. Store the private key PEM in CUDly as azure_wif_private_key.\n\n")
sb.WriteString("This uses true Workload Identity Federation — no certificate, no private key,\nno client secret is created or stored. CUDly's OIDC issuer signs a short-lived\nJWT on each Azure API call and Azure AD verifies it against the federated\ncredential provisioned here.\n\n")
sb.WriteString("Deploy (Terraform):\n")
sb.WriteString(" cd terraform && terraform init && terraform apply\n\n")
sb.WriteString("After apply, set azure_auth_mode=workload_identity_federation in CUDly.\n")
Expand Down
15 changes: 5 additions & 10 deletions internal/api/handler_registrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,23 +312,18 @@ func (h *Handler) approveRegistration(ctx context.Context, httpReq *events.Lambd
// ambient instance credentials (role assumption, managed identity,
// Application Default Credentials) or via CUDly's KMS-backed OIDC
// federated path. These accounts should auto-enable on approval
// because there's no follow-up "upload the PEM/JSON" step.
//
// Cert-based legacy Azure WIF is NOT included here — it needs a
// stored azure_wif_private_key blob that the operator uploads
// separately after approval, so those accounts keep the old
// opt-in-via-manual-PUT behaviour.
// because there's no follow-up "upload the JSON" step.
func accountHasCredentialFreePath(acct *config.CloudAccount) bool {
switch acct.Provider {
case "aws":
// role_arn flows via STS AssumeRole with ambient CUDly creds;
// workload_identity_federation mints its own token file.
return acct.AWSAuthMode == "role_arn" || acct.AWSAuthMode == "workload_identity_federation"
case "azure":
// managed_identity: ambient. workload_identity_federation:
// federated via the KMS-backed path when no PEM is stored —
// the /test handler falls back to the cert path when one is
// present, so auto-enabling the account either way is fine.
// managed_identity is ambient; workload_identity_federation
// uses the KMS-backed OIDC signer to mint a client assertion
// on every call — nothing is stored in CUDly per-account, so
// auto-enable is always safe for either mode.
return acct.AzureAuthMode == "managed_identity" || acct.AzureAuthMode == "workload_identity_federation"
case "gcp":
// application_default: ambient (Cloud Run / GKE). WIF:
Expand Down
7 changes: 3 additions & 4 deletions internal/api/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,8 @@ type credentialPayloadSchema struct {
}

var credentialPayloadSchemas = map[string]credentialPayloadSchema{
"aws_access_keys": {required: []string{"access_key_id", "secret_access_key"}},
"azure_client_secret": {required: []string{"client_secret"}},
"azure_wif_private_key": {required: []string{"private_key_pem"}},
"aws_access_keys": {required: []string{"access_key_id", "secret_access_key"}},
"azure_client_secret": {required: []string{"client_secret"}},
}

// gcpServiceAccountKeys are the fields a Google service-account JSON file is
Expand Down Expand Up @@ -110,7 +109,7 @@ func validateCredentialPayload(credentialType string, payload map[string]interfa
}

switch credentialType {
case "aws_access_keys", "azure_client_secret", "azure_wif_private_key":
case "aws_access_keys", "azure_client_secret":
schema := credentialPayloadSchemas[credentialType]
return validateFlatPayload(credentialType, payload, schema.required, schema.optional)
case "gcp_service_account":
Expand Down
7 changes: 0 additions & 7 deletions internal/api/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,6 @@ func TestValidateCredentialPayload(t *testing.T) {
{"azure secret unknown key", "azure_client_secret",
map[string]interface{}{"some_other": "abc"}, "unknown key \"some_other\""},

// azure_wif_private_key
{"azure wif happy", "azure_wif_private_key",
map[string]interface{}{"private_key_pem": "-----BEGIN..."}, ""},
{"azure wif unknown key", "azure_wif_private_key",
map[string]interface{}{"private_key_pem": "x", "thumbprint": "abc"},
"unknown key \"thumbprint\""},

// gcp_service_account
{"gcp svc happy", "gcp_service_account",
map[string]interface{}{
Expand Down
52 changes: 13 additions & 39 deletions internal/credentials/azure_federated.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,58 +39,32 @@ type AzureResolveOptions struct {
}

// resolveAzureWIFCredential handles the workload_identity_federation
// auth mode. Extracted from ResolveAzureTokenCredentialWithOpts to keep
// the top-level switch simple.
//
// Routing:
// - opts.Signer + issuer URL available, no stored PEM → federated
// path (BuildAzureFederatedCredential), secret-free.
// - opts.Signer set but a stored PEM exists → legacy cert path, for
// backward compatibility with accounts registered before the
// redesign.
// - opts.Signer not set → legacy cert path, requiring a stored PEM.
// auth mode. The only supported path is the secret-free federated one:
// CUDly's deployment-wide OIDC signer (KMS-backed) mints a short-lived
// client-assertion JWT that Azure AD validates against the App
// Registration's federated-identity-credential binding. No secret
// material is ever stored in CUDly.
//
// The issuer URL comes from opts.IssuerURL if set, otherwise from the
// package-level oidc.IssuerURL() cache populated by the first inbound
// HTTP request — see internal/oidc/issuer_cache.go.
//
// `store` is accepted for signature parity with the other resolver
// helpers; it is unused here because no credential is ever loaded.
func resolveAzureWIFCredential(
ctx context.Context,
_ context.Context,
account *config.CloudAccount,
store CredentialStore,
_ CredentialStore,
opts AzureResolveOptions,
) (azcore.TokenCredential, error) {
raw, _ := loadOptionalWIFKey(ctx, store, account.ID)

issuerURL := opts.IssuerURL
if issuerURL == "" {
issuerURL = oidc.IssuerURL()
}

// Secret-free federated path — opt-in via signer+issuerURL, only
// when the account has no legacy PEM stored.
if opts.Signer != nil && issuerURL != "" && len(raw) == 0 {
return BuildAzureFederatedCredential(opts.Signer, issuerURL, account.AzureTenantID, account.AzureClientID)
}

// Legacy cert-based path.
if store == nil {
return nil, fmt.Errorf("credentials: credential store required for azure wif account %s", account.ID)
}
if len(raw) == 0 {
return nil, fmt.Errorf("credentials: no wif key stored for account %s", account.ID)
}
return buildAzureWIFCredential(account, raw)
}

// loadOptionalWIFKey attempts to read the stored Azure WIF PEM blob for
// an account. Returns (nil, nil) when the store is absent, when the
// blob is unset, or when the load fails — callers use the absence of
// a blob to route, not the error.
func loadOptionalWIFKey(ctx context.Context, store CredentialStore, accountID string) ([]byte, error) {
if store == nil {
return nil, nil
if opts.Signer == nil || issuerURL == "" {
return nil, fmt.Errorf("credentials: azure workload_identity_federation requires a wired OIDC signer and issuer URL (account %s)", account.ID)
}
return store.LoadRaw(ctx, accountID, CredTypeAzureWIF)
return BuildAzureFederatedCredential(opts.Signer, issuerURL, account.AzureTenantID, account.AzureClientID)
}

// BuildAzureFederatedCredential returns an azcore.TokenCredential that
Expand Down
Loading