From b7bc8cf19dd2fed74eecdb6225eac0f11d171832 Mon Sep 17 00:00:00 2001 From: namnt2307 Date: Tue, 14 Jul 2026 11:53:21 +0700 Subject: [PATCH] Add Azure Workload Identity auth for Azure DevOps Add a secretless auth mode for Azure DevOps repos using Azure Workload Identity Federation. The pod's projected ServiceAccount token is exchanged with Microsoft Entra ID for an access token, which is stored as the git credential. Enabled with --azure-workload-identity; client ID, tenant ID, token file, authority host, and scope are configurable via flags, each falling back to the AZURE_* env vars injected by the azure-workload-identity webhook. Mirrors the existing GitHub App auth mode. --- README.md | 33 +++++ azure_workload_identity.go | 175 +++++++++++++++++++++++ azure_workload_identity_test.go | 238 ++++++++++++++++++++++++++++++++ docs/azure-workload-identity.md | 189 +++++++++++++++++++++++++ main.go | 91 +++++++++--- 5 files changed, 710 insertions(+), 16 deletions(-) create mode 100644 azure_workload_identity.go create mode 100644 azure_workload_identity_test.go create mode 100644 docs/azure-workload-identity.md diff --git a/README.md b/README.md index e9a4b1223..9e36a2717 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,39 @@ OPTIONS (200) and produce a series of key=value lines, including "username=" and "password=". + --azure-workload-identity, $GITSYNC_AZURE_WORKLOAD_IDENTITY + Use Azure Workload Identity Federation to authenticate to Azure + DevOps. The pod's projected ServiceAccount token is exchanged for + a Microsoft Entra ID access token which is used as the git + credential, so no PAT or secret is stored. May not be combined + with --username/--password, --askpass-url, --credential, or the + --github-app-* flags. + + --azure-client-id , $GITSYNC_AZURE_CLIENT_ID + The Microsoft Entra ID client ID to use for Azure workload identity + authentication. If not specified, defaults to $AZURE_CLIENT_ID, + which is normally injected by the azure-workload-identity webhook. + + --azure-tenant-id , $GITSYNC_AZURE_TENANT_ID + The Microsoft Entra ID tenant ID to use for Azure workload identity + authentication. If not specified, defaults to $AZURE_TENANT_ID. + + --azure-federated-token-file , $GITSYNC_AZURE_FEDERATED_TOKEN_FILE + The path to the projected ServiceAccount token used for Azure + workload identity authentication. If not specified, defaults to + $AZURE_FEDERATED_TOKEN_FILE. + + --azure-authority-host , $GITSYNC_AZURE_AUTHORITY_HOST + The Microsoft Entra ID authority host to use for Azure workload + identity authentication. If not specified, defaults to + $AZURE_AUTHORITY_HOST, or https://login.microsoftonline.com/ when + that is unset. Override only for sovereign clouds. + + --azure-scope , $GITSYNC_AZURE_SCOPE + The OAuth2 scope to request when exchanging the federated token for + an access token. If not specified, defaults to the Azure DevOps + resource ID (499b84ac-1321-427f-aa17-267ca6975798/.default). + --cookie-file , $GITSYNC_COOKIE_FILE Use a git cookiefile (/etc/git-secret/cookie_file) for authentication. diff --git a/azure_workload_identity.go b/azure_workload_identity.go new file mode 100644 index 000000000..52cfb650f --- /dev/null +++ b/azure_workload_identity.go @@ -0,0 +1,175 @@ +/* +Copyright 2026 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// Env vars injected by the azure-workload-identity mutating admission webhook. +const ( + envAzureClientID = "AZURE_CLIENT_ID" + envAzureTenantID = "AZURE_TENANT_ID" + envAzureFederatedTokenFile = "AZURE_FEDERATED_TOKEN_FILE" + envAzureAuthorityHost = "AZURE_AUTHORITY_HOST" +) + +// JWT-bearer client-assertion type, per RFC 7521 / RFC 7523. +const azureClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + +// azureTokenResponse models the subset of the AAD v2.0 token endpoint response +// we care about. Other fields (ext_expires_in, etc.) are intentionally ignored. +type azureTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +// azureTokenError models the AAD v2.0 token endpoint error response. +type azureTokenError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorCodes []int `json:"error_codes"` + CorrelationID string `json:"correlation_id"` +} + +// azureTokenEndpoint builds the AAD v2.0 token endpoint URL from the values +// injected by the azure-workload-identity webhook. authorityHost is +// expected to be a URL like "https://login.microsoftonline.com/" but is +// normalized to tolerate a missing trailing slash. +func azureTokenEndpoint(authorityHost, tenantID string) (string, error) { + if authorityHost == "" { + return "", fmt.Errorf("$%s is empty", envAzureAuthorityHost) + } + if tenantID == "" { + return "", fmt.Errorf("$%s is empty", envAzureTenantID) + } + u, err := url.Parse(strings.TrimRight(authorityHost, "/") + "/") + if err != nil { + return "", fmt.Errorf("invalid $%s %q: %w", envAzureAuthorityHost, authorityHost, err) + } + return u.String() + tenantID + "/oauth2/v2.0/token", nil +} + +// RefreshAzureWIToken exchanges the projected ServiceAccount token at +// tokenFile for an Azure AD access token (scoped, by default, to the Azure +// DevOps resource) and stores it as a git credential against git.repo. It is +// safe to call repeatedly; the AAD endpoint mints a fresh access token each +// call. The caller resolves clientID/tenantID/tokenFile/authorityHost/scope +// from flags (or their AZURE_* env fallbacks) and decides when to call this +// (see the azureWITokenExpiry skew check in main.go's refreshCreds closure). +func (git *repoSync) RefreshAzureWIToken(ctx context.Context, clientID, tenantID, tokenFile, authorityHost, scope string) error { + git.log.V(3).Info("refreshing Azure Workload Identity token") + + saTokenBytes, err := os.ReadFile(tokenFile) + if err != nil { + return fmt.Errorf("can't read federated token file %q: %w", tokenFile, err) + } + saToken := strings.TrimSpace(string(saTokenBytes)) + if saToken == "" { + return fmt.Errorf("federated token file %q is empty", tokenFile) + } + + endpoint, err := azureTokenEndpoint(authorityHost, tenantID) + if err != nil { + return err + } + + tokenResp, err := exchangeAzureFederatedToken(ctx, endpoint, clientID, scope, saToken) + if err != nil { + return err + } + + git.azureWITokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + + // Azure DevOps accepts an AAD access token as the password of an HTTP basic + // auth credential with any non-empty username. We use "-" to match the + // convention already used by the GitHub App auth path in this codebase. + if err := git.StoreCredentials(ctx, git.repo, "-", tokenResp.AccessToken); err != nil { + return fmt.Errorf("can't store Azure WI access token as git credential: %w", err) + } + + return nil +} + +// exchangeAzureFederatedToken performs the OAuth2 client-credentials grant +// against the AAD v2.0 token endpoint, using the projected ServiceAccount +// token as the JWT-bearer client assertion. It returns the parsed token +// response or an error. The returned ExpiresIn is normalized to a positive +// value (defaulting to 300s if AAD returned 0 or a negative value). +// +// This is split out from RefreshAzureWIToken so it can be unit-tested against +// an httptest server without needing a real git environment. +func exchangeAzureFederatedToken(ctx context.Context, endpoint, clientID, scope, federatedToken string) (*azureTokenResponse, error) { + form := url.Values{ + "client_id": {clientID}, + "scope": {scope}, + "client_assertion_type": {azureClientAssertionType}, + "client_assertion": {federatedToken}, + "grant_type": {"client_credentials"}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("can't build AAD token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("AAD token request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("can't read AAD token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var aadErr azureTokenError + if jerr := json.Unmarshal(body, &aadErr); jerr == nil && aadErr.Error != "" { + return nil, fmt.Errorf("AAD token endpoint returned %d: %s: %s (correlation_id=%s, error_codes=%v)", + resp.StatusCode, aadErr.Error, aadErr.ErrorDescription, aadErr.CorrelationID, aadErr.ErrorCodes) + } + return nil, fmt.Errorf("AAD token endpoint returned %d, body: %q", resp.StatusCode, string(body)) + } + + var tokenResp azureTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("can't parse AAD token response: %w", err) + } + if tokenResp.AccessToken == "" { + return nil, fmt.Errorf("AAD token response contained no access_token") + } + if tokenResp.ExpiresIn <= 0 { + // AAD always returns expires_in; if it didn't, assume a conservative 5 min + // so we re-mint soon rather than holding a token forever. + tokenResp.ExpiresIn = 300 + } + return &tokenResp, nil +} diff --git a/azure_workload_identity_test.go b/azure_workload_identity_test.go new file mode 100644 index 000000000..e450023cc --- /dev/null +++ b/azure_workload_identity_test.go @@ -0,0 +1,238 @@ +/* +Copyright 2026 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestAzureTokenEndpoint(t *testing.T) { + cases := []struct { + name string + authorityHost string + tenantID string + want string + wantErr bool + }{ + { + name: "trailing slash on authority", + authorityHost: "https://login.microsoftonline.com/", + tenantID: "deadbeef-1111-2222-3333-444455556666", + want: "https://login.microsoftonline.com/deadbeef-1111-2222-3333-444455556666/oauth2/v2.0/token", + }, + { + name: "no trailing slash on authority", + authorityHost: "https://login.microsoftonline.com", + tenantID: "deadbeef-1111-2222-3333-444455556666", + want: "https://login.microsoftonline.com/deadbeef-1111-2222-3333-444455556666/oauth2/v2.0/token", + }, + { + name: "sovereign cloud authority", + authorityHost: "https://login.microsoftonline.us/", + tenantID: "tenant", + want: "https://login.microsoftonline.us/tenant/oauth2/v2.0/token", + }, + { + name: "empty authority", + authorityHost: "", + tenantID: "tenant", + wantErr: true, + }, + { + name: "empty tenant", + authorityHost: "https://login.microsoftonline.com/", + tenantID: "", + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := azureTokenEndpoint(tc.authorityHost, tc.tenantID) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +// TestExchangeAzureFederatedToken_RequestShape verifies that the OAuth2 +// request we send to AAD is well-formed: correct method, content-type, +// and all five required form fields with the expected values. +func TestExchangeAzureFederatedToken_RequestShape(t *testing.T) { + const ( + wantClientID = "11111111-2222-3333-4444-555555555555" + wantScope = "499b84ac-1321-427f-aa17-267ca6975798/.default" + wantSAToken = "eyJhbGciOi.fake.federated" + fakeAccess = "eyJ0eXAiOi.fake.access" + ) + + var ( + gotMethod string + gotContentType string + gotAccept string + gotForm url.Values + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotContentType = r.Header.Get("Content-Type") + gotAccept = r.Header.Get("Accept") + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + } + gotForm = r.PostForm + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(azureTokenResponse{ + AccessToken: fakeAccess, + ExpiresIn: 3599, + TokenType: "Bearer", + }) + })) + defer srv.Close() + + tr, err := exchangeAzureFederatedToken(context.Background(), srv.URL, wantClientID, wantScope, wantSAToken) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tr.AccessToken != fakeAccess { + t.Errorf("access_token: got %q want %q", tr.AccessToken, fakeAccess) + } + if tr.ExpiresIn != 3599 { + t.Errorf("expires_in: got %d want 3599", tr.ExpiresIn) + } + + if gotMethod != http.MethodPost { + t.Errorf("method: got %q want POST", gotMethod) + } + if gotContentType != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type: got %q", gotContentType) + } + if gotAccept != "application/json" { + t.Errorf("Accept: got %q", gotAccept) + } + + checks := map[string]string{ + "client_id": wantClientID, + "scope": wantScope, + "client_assertion_type": azureClientAssertionType, + "client_assertion": wantSAToken, + "grant_type": "client_credentials", + } + for k, want := range checks { + got := gotForm.Get(k) + if got != want { + t.Errorf("form[%q]: got %q want %q", k, got, want) + } + } +} + +// TestExchangeAzureFederatedToken_AADError verifies that AAD-style JSON +// error responses are surfaced with their AADSTS code and correlation ID, +// not just a generic "status N" message. +func TestExchangeAzureFederatedToken_AADError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(azureTokenError{ + Error: "invalid_client", + ErrorDescription: "AADSTS70021: No matching federated identity record found", + ErrorCodes: []int{70021}, + CorrelationID: "abc-123", + }) + })) + defer srv.Close() + + _, err := exchangeAzureFederatedToken(context.Background(), srv.URL, "cid", "scope", "token") + if err == nil { + t.Fatal("expected error, got nil") + } + for _, want := range []string{"invalid_client", "AADSTS70021", "abc-123"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q missing %q", err.Error(), want) + } + } +} + +// TestExchangeAzureFederatedToken_NonJSONError exercises the fallback path +// when AAD (or a proxy in front of it) returns a non-JSON error body. +func TestExchangeAzureFederatedToken_NonJSONError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, "upstream is having a bad day") + })) + defer srv.Close() + + _, err := exchangeAzureFederatedToken(context.Background(), srv.URL, "cid", "scope", "token") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "502") { + t.Errorf("error %q missing status code", err.Error()) + } + if !strings.Contains(err.Error(), "upstream is having a bad day") { + t.Errorf("error %q missing raw body", err.Error()) + } +} + +// TestExchangeAzureFederatedToken_EmptyAccessToken catches a malformed but +// 200-OK response — we should reject it rather than store an empty token. +func TestExchangeAzureFederatedToken_EmptyAccessToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"expires_in":3599}`) + })) + defer srv.Close() + + _, err := exchangeAzureFederatedToken(context.Background(), srv.URL, "cid", "scope", "token") + if err == nil { + t.Fatal("expected error for empty access_token, got nil") + } +} + +// TestExchangeAzureFederatedToken_ZeroExpiresInDefaults verifies the +// defensive defaulting when AAD (or a fake) omits or zeroes expires_in. +func TestExchangeAzureFederatedToken_ZeroExpiresInDefaults(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"access_token":"abc","expires_in":0}`) + })) + defer srv.Close() + + tr, err := exchangeAzureFederatedToken(context.Background(), srv.URL, "cid", "scope", "token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tr.ExpiresIn != 300 { + t.Errorf("expected ExpiresIn defaulted to 300, got %d", tr.ExpiresIn) + } +} diff --git a/docs/azure-workload-identity.md b/docs/azure-workload-identity.md new file mode 100644 index 000000000..eed7d1162 --- /dev/null +++ b/docs/azure-workload-identity.md @@ -0,0 +1,189 @@ +# Authenticating to Azure DevOps with Azure Workload Identity Federation + +git-sync supports authenticating to Azure DevOps Services (`dev.azure.com`) +without any long-lived secret, by exchanging the pod's projected +ServiceAccount token for a short-lived Microsoft Entra ID (Azure AD) +access token. + +This relies on +[Azure Workload Identity for Kubernetes](https://azure.github.io/azure-workload-identity/), +specifically the mutating admission webhook that injects the necessary env +vars and projected token volume into pods that opt in. + +When you use this feature, **no PAT or password lives in a Kubernetes +Secret**. The federated SA token is rotated automatically by kubelet, and +git-sync re-mints the Entra access token before it expires. + +## When to use this + +- Your git repo is on Azure DevOps Services (`https://dev.azure.com//...`). +- Your cluster has the + [`azure-workload-identity`](https://azure.github.io/azure-workload-identity/docs/installation.html) + webhook installed. +- You'd rather not manage a PAT. + +If your repo is on Azure DevOps **Server** (self-hosted) or you're using +SSH (`git@ssh.dev.azure.com:...`), this feature does not apply — use a +PAT or SSH key instead. + +## Step 1: prerequisites on the Azure side + +You need: + +1. A Microsoft Entra ID application registration **or** a user-assigned + managed identity. Note its **client ID** and **tenant ID**. + +2. A **federated identity credential** on that app registration / managed + identity, with: + + - **Issuer:** the OIDC issuer URL of your Kubernetes cluster + (`kubectl get --raw /.well-known/openid-configuration | jq -r .issuer`). + - **Subject:** `system:serviceaccount::` + (the namespace + name of the ServiceAccount that git-sync's pod will + run as). + - **Audience:** `api://AzureADTokenExchange` (the AKS / OIDC default). + +3. The application / managed identity must be **granted access to your + Azure DevOps organization**. The simplest path: + + - In Azure DevOps, go to *Organization settings → Users → Add users*. + - Add the app registration / managed identity as a member (search by + its display name). + - Grant it the necessary access level and at least Reader on the + project / repo. For a write-back use case you'd want Contributor. + +## Step 2: prerequisites on the Kubernetes side + +Make sure the +[`azure-workload-identity`](https://azure.github.io/azure-workload-identity/docs/installation.html) +mutating webhook is installed in the cluster. + +Then create (or annotate) the ServiceAccount that git-sync runs under: + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: git-sync + namespace: my-namespace + annotations: + azure.workload.identity/client-id: "" +``` + +## Step 3: configure the git-sync pod + +Two things matter: + +1. The **pod must have the label** `azure.workload.identity/use: "true"`. + This is what tells the webhook to mutate it. +2. **git-sync** must be started with `--azure-workload-identity` (or + `GITSYNC_AZURE_WORKLOAD_IDENTITY=true`). + +A minimal example: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: git-sync + namespace: my-namespace + labels: + azure.workload.identity/use: "true" +spec: + serviceAccountName: git-sync + containers: + - name: git-sync + image: registry.k8s.io/git-sync/git-sync:v4 + env: + - name: GITSYNC_REPO + value: "https://dev.azure.com///_git/" + - name: GITSYNC_ROOT + value: /tmp/git + - name: GITSYNC_REF + value: main + - name: GITSYNC_AZURE_WORKLOAD_IDENTITY + value: "true" + volumeMounts: + - name: out + mountPath: /tmp/git + volumes: + - name: out + emptyDir: {} +``` + +You do **not** need to set `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, +`AZURE_FEDERATED_TOKEN_FILE`, or `AZURE_AUTHORITY_HOST` yourself — the +webhook injects them when it sees the `azure.workload.identity/use: "true"` +label and the SA annotation. If you run without the webhook, or want to +override any of them, set the matching `--azure-*` flag (or `GITSYNC_AZURE_*` +env var) instead. + +You also do **not** need `--username` / `--password` / `--password-file` / +`--askpass-url`; git-sync refuses to start if any of those are combined +with `--azure-workload-identity`. + +## Flags + +| Flag | Env var | Default | Meaning | +|-------------------------------|----------------------------------|------------------------------------------------------|---------| +| `--azure-workload-identity` | `GITSYNC_AZURE_WORKLOAD_IDENTITY`| `false` | Opt-in: use AWI for HTTPS auth | +| `--azure-client-id` | `GITSYNC_AZURE_CLIENT_ID`, `AZURE_CLIENT_ID` | (from webhook) | Entra client ID. Falls back to the webhook-injected `AZURE_CLIENT_ID`. | +| `--azure-tenant-id` | `GITSYNC_AZURE_TENANT_ID`, `AZURE_TENANT_ID` | (from webhook) | Entra tenant ID. Falls back to `AZURE_TENANT_ID`. | +| `--azure-federated-token-file`| `GITSYNC_AZURE_FEDERATED_TOKEN_FILE`, `AZURE_FEDERATED_TOKEN_FILE` | (from webhook) | Path to the projected federated token. Falls back to `AZURE_FEDERATED_TOKEN_FILE`. | +| `--azure-authority-host` | `GITSYNC_AZURE_AUTHORITY_HOST`, `AZURE_AUTHORITY_HOST` | `https://login.microsoftonline.com/`| Entra authority host. Override only for sovereign clouds. | +| `--azure-scope` | `GITSYNC_AZURE_SCOPE` | `499b84ac-1321-427f-aa17-267ca6975798/.default` | OAuth2 scope. Default is the Azure DevOps resource ID for the public cloud — override only for sovereign clouds or other Entra-protected resources. | + +## Troubleshooting + +**git-sync fails on startup with** *"--azure-workload-identity requires +--azure-client-id, --azure-tenant-id, and --azure-federated-token-file"* + +The webhook didn't mutate the pod (and you didn't set the flags yourself). +Check: + +- The pod has label `azure.workload.identity/use: "true"`. +- The ServiceAccount has annotation `azure.workload.identity/client-id`. +- The `azure-workload-identity` webhook is installed and its namespace + selector (if any) covers the pod's namespace. +- The webhook pod itself is healthy: `kubectl -n azure-workload-identity-system get pods`. + +**AADSTS70021: No matching federated identity record found** + +The federated identity credential on the Entra app registration doesn't +match. The `subject` must be exactly +`system:serviceaccount::`, the +`audience` must be `api://AzureADTokenExchange`, and the `issuer` must +be your cluster's actual OIDC issuer URL. Get the issuer with: + +``` +kubectl get --raw /.well-known/openid-configuration | jq -r .issuer +``` + +**AADSTS700213: No matching federated identity record found for +presented assertion subject** + +Same root cause as AADSTS70021 — the `subject` field in the federated +credential doesn't match the SA. Note the namespace and SA name are +both case-sensitive. + +**git fetch fails with TF401019 or 401 from Azure DevOps** + +The Entra identity isn't authorized in the ADO org/project. Add it as +a user in *Organization settings → Users* and grant it project +permissions. The token-exchange itself can succeed while the resulting +token still lacks ADO access. + +**Token works but expires mid-sync on a long clone** + +Entra access tokens are typically valid for ~1 hour. If a single sync +takes longer than that and the connection needs to re-authenticate, +you'll see a mid-clone failure. git-sync refreshes between syncs, not +during a single git operation. Consider `--depth`, `--shallow-since`, +or partial-clone (`--filter`) to keep individual fetches short. + +## Caveat: token visibility inside the pod + +The Entra access token is stored in git's credential cache for the +duration of its expiry. Anyone with shell access into the git-sync pod +can read it. The pod is the trust boundary; mount no other +untrusted code into it. diff --git a/main.go b/main.go index 99fdf840a..a0dafa71b 100644 --- a/main.go +++ b/main.go @@ -78,6 +78,11 @@ var ( Name: "git_sync_refresh_github_app_token_count", Help: "How many times the GitHub app token was refreshed, partitioned by state (success, error)", }, []string{"status"}) + + metricRefreshAzureWITokenCount = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "git_sync_refresh_azure_wi_token_count", + Help: "How many times the Azure Workload Identity token was refreshed, partitioned by state (success, error)", + }, []string{"status"}) ) func init() { @@ -86,6 +91,7 @@ func init() { prometheus.MustRegister(metricFetchCount) prometheus.MustRegister(metricAskpassCount) prometheus.MustRegister(metricRefreshGitHubAppTokenCount) + prometheus.MustRegister(metricRefreshAzureWITokenCount) } const ( @@ -115,22 +121,23 @@ const defaultDirMode = os.FileMode(0775) // subject to umask // repoSync represents the remote repo and the local sync of it. type repoSync struct { - cmd string // the git command to run - root absPath // absolute path to the root directory - repo string // remote repo to sync - ref string // the ref to sync - depth int // for shallow sync - filter string // for partial clone - submodules submodulesMode // how to handle submodules - gc gcMode // garbage collection - link absPath // absolute path to the symlink to publish - authURL string // a URL to re-fetch credentials, or "" - sparseFile string // path to a sparse-checkout file - syncCount int // how many times have we synced? - log *logging.Logger - run cmd.Runner - staleTimeout time.Duration // time for worktrees to be cleaned up - appTokenExpiry time.Time // time when github app auth token expires + cmd string // the git command to run + root absPath // absolute path to the root directory + repo string // remote repo to sync + ref string // the ref to sync + depth int // for shallow sync + filter string // for partial clone + submodules submodulesMode // how to handle submodules + gc gcMode // garbage collection + link absPath // absolute path to the symlink to publish + authURL string // a URL to re-fetch credentials, or "" + sparseFile string // path to a sparse-checkout file + syncCount int // how many times have we synced? + log *logging.Logger + run cmd.Runner + staleTimeout time.Duration // time for worktrees to be cleaned up + appTokenExpiry time.Time // time when github app auth token expires + azureWITokenExpiry time.Time // time when Azure Workload Identity token expires } func main() { @@ -293,6 +300,25 @@ func main() { envInt(0, "GITSYNC_GITHUB_APP_INSTALLATION_ID"), "the GitHub app installation ID to use for GitHub app auth") + flAzureWorkloadIdentity := pflag.Bool("azure-workload-identity", + envBool(false, "GITSYNC_AZURE_WORKLOAD_IDENTITY"), + "use Azure Workload Identity Federation to authenticate to Azure DevOps") + flAzureClientID := pflag.String("azure-client-id", + envString("", "GITSYNC_AZURE_CLIENT_ID", envAzureClientID), + "the Azure AD client ID for workload identity auth (defaults to $AZURE_CLIENT_ID from the azure-workload-identity webhook)") + flAzureTenantID := pflag.String("azure-tenant-id", + envString("", "GITSYNC_AZURE_TENANT_ID", envAzureTenantID), + "the Azure AD tenant ID for workload identity auth (defaults to $AZURE_TENANT_ID)") + flAzureFederatedTokenFile := pflag.String("azure-federated-token-file", + envString("", "GITSYNC_AZURE_FEDERATED_TOKEN_FILE", envAzureFederatedTokenFile), + "the path to the projected federated token file (defaults to $AZURE_FEDERATED_TOKEN_FILE)") + flAzureAuthorityHost := pflag.String("azure-authority-host", + envString("https://login.microsoftonline.com/", "GITSYNC_AZURE_AUTHORITY_HOST", envAzureAuthorityHost), + "the Azure AD authority host (defaults to $AZURE_AUTHORITY_HOST or the public cloud endpoint)") + flAzureScope := pflag.String("azure-scope", + envString("499b84ac-1321-427f-aa17-267ca6975798/.default", "GITSYNC_AZURE_SCOPE"), + "the OAuth2 scope to exchange the federated token for (defaults to the Azure DevOps resource ID)") + flGitCmd := pflag.String("git", envString("git", "GITSYNC_GIT", "GIT_SYNC_GIT"), "the git command to run (subject to PATH search, mostly for testing)") @@ -613,6 +639,29 @@ func main() { } } + if *flAzureWorkloadIdentity { + if *flUsername != "" || *flPassword != "" || *flPasswordFile != "" { + fatalConfigErrorf(log, true, "invalid flag: --username/--password/--password-file may not be specified when --azure-workload-identity is set") + } + if *flAskPassURL != "" { + fatalConfigErrorf(log, true, "invalid flag: --askpass-url may not be specified when --azure-workload-identity is set") + } + if *flGithubAppApplicationID != 0 || *flGithubAppClientID != "" { + fatalConfigErrorf(log, true, "invalid flag: --github-app-* flags may not be specified when --azure-workload-identity is set") + } + if len(*flCredentials) > 0 { + fatalConfigErrorf(log, true, "invalid flag: --credential may not be specified when --azure-workload-identity is set") + } + if *flAzureClientID == "" || *flAzureTenantID == "" || *flAzureFederatedTokenFile == "" { + fatalConfigErrorf(log, true, "invalid config: --azure-workload-identity requires --azure-client-id, --azure-tenant-id, and --azure-federated-token-file (or their AZURE_* env vars, normally injected by the azure-workload-identity webhook)") + } + if u, err := url.Parse(*flRepo); err == nil { + if u.Scheme != "https" { + fatalConfigErrorf(log, true, "invalid flag: --repo must be an https:// URL when --azure-workload-identity is set, got scheme %q", u.Scheme) + } + } + } + if len(*flCredentials) > 0 { for _, cred := range *flCredentials { if cred.URL == "" { @@ -928,6 +977,16 @@ func main() { } } + if *flAzureWorkloadIdentity { + if git.azureWITokenExpiry.Before(time.Now().Add(30 * time.Second)) { + if err := git.RefreshAzureWIToken(ctx, *flAzureClientID, *flAzureTenantID, *flAzureFederatedTokenFile, *flAzureAuthorityHost, *flAzureScope); err != nil { + metricRefreshAzureWITokenCount.WithLabelValues(metricKeyError).Inc() + return err + } + metricRefreshAzureWITokenCount.WithLabelValues(metricKeySuccess).Inc() + } + } + return nil }