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 }