diff --git a/cli/azd/pkg/commands/pipeline/github_provider.go b/cli/azd/pkg/commands/pipeline/github_provider.go index e942e1e7f41..64c1b24b81d 100644 --- a/cli/azd/pkg/commands/pipeline/github_provider.go +++ b/cli/azd/pkg/commands/pipeline/github_provider.go @@ -609,7 +609,7 @@ func (p *GitHubCiProvider) configureFederatedAuth( return fmt.Errorf("failed unmarshalling azure credentials: %w", err) } - err = applyFederatedCredentials(ctx, repoSlug, &azureCredentials, p.console, credential) + err = applyFederatedCredentials(ctx, repoSlug, &azureCredentials, p.console, credential, ghCli) if err != nil { return err } @@ -645,6 +645,7 @@ func applyFederatedCredentials( azureCredentials *azcli.AzureCredentials, console input.Console, credential azcore.TokenCredential, + ghCli github.GitHubCli, ) error { graphClient, err := createGraphClient(ctx, credential) if err != nil { @@ -670,19 +671,36 @@ func applyFederatedCredentials( return fmt.Errorf("failed retrieving federated credentials: %w", err) } + // Query GitHub OIDC subject claim customization for the repo/org + oidcConfig, err := ghCli.GetOIDCSubjectForRepo(ctx, repoSlug) + if err != nil { + log.Printf("Warning: failed to query OIDC subject claim config, using default format: %v", err) + oidcConfig = &github.OIDCSubjectConfig{UseDefault: true} + } + + mainSubject, err := github.BuildOIDCSubject(ctx, ghCli, repoSlug, oidcConfig, "ref:refs/heads/main") + if err != nil { + return fmt.Errorf("failed to build OIDC subject for main branch: %w", err) + } + + prSubject, err := github.BuildOIDCSubject(ctx, ghCli, repoSlug, oidcConfig, "pull_request") + if err != nil { + return fmt.Errorf("failed to build OIDC subject for pull requests: %w", err) + } + // List of desired federated credentials federatedCredentials := []graphsdk.FederatedIdentityCredential{ { Name: "main", Issuer: federatedIdentityIssuer, - Subject: fmt.Sprintf("repo:%s:ref:refs/heads/main", repoSlug), + Subject: mainSubject, Description: convert.RefOf("Created by Azure Developer CLI"), Audiences: []string{federatedIdentityAudience}, }, { Name: "pull_request", Issuer: federatedIdentityIssuer, - Subject: fmt.Sprintf("repo:%s:pull_request", repoSlug), + Subject: prSubject, Description: convert.RefOf("Created by Azure Developer CLI"), Audiences: []string{federatedIdentityAudience}, }, diff --git a/cli/azd/pkg/tools/github/github.go b/cli/azd/pkg/tools/github/github.go index 456a26477c6..6168e65c93a 100644 --- a/cli/azd/pkg/tools/github/github.go +++ b/cli/azd/pkg/tools/github/github.go @@ -46,6 +46,10 @@ type GitHubCli interface { CreatePrivateRepository(ctx context.Context, name string) error GetGitProtocolType(ctx context.Context) (string, error) GitHubActionsExists(ctx context.Context, repoSlug string) (bool, error) + // GetOIDCSubjectForRepo returns the OIDC subject claim format for a repo by querying + // the GitHub OIDC customization API. If the org/repo uses a custom subject template, + // the returned OIDCSubjectConfig will have UseDefault=false and IncludeClaimKeys set. + GetOIDCSubjectForRepo(ctx context.Context, repoSlug string) (*OIDCSubjectConfig, error) } func NewGitHubCli(ctx context.Context, console input.Console, commandRunner exec.CommandRunner) (GitHubCli, error) { @@ -311,6 +315,103 @@ func (cli *ghCli) GitHubActionsExists(ctx context.Context, repoSlug string) (boo return true, nil } +// OIDCSubjectConfig represents the OIDC subject claim customization for a GitHub repository or org. +type OIDCSubjectConfig struct { + UseDefault bool `json:"use_default"` + IncludeClaimKeys []string `json:"include_claim_keys"` +} + +// gitHubRepoInfo holds GitHub API repo metadata needed for OIDC subject construction. +type gitHubRepoInfo struct { + ID int `json:"id"` + Owner struct { + ID int `json:"id"` + } `json:"owner"` +} + +// BuildOIDCSubject constructs the correct OIDC subject claim for a federated identity credential. +// If the org/repo uses custom claim keys (e.g. repository_owner_id, repository_id), this function +// queries the GitHub API for the numeric IDs and builds the subject accordingly. +// The suffix is the trailing part of the subject, e.g. "ref:refs/heads/main" or "pull_request". +func BuildOIDCSubject( + ctx context.Context, cli GitHubCli, repoSlug string, oidcConfig *OIDCSubjectConfig, suffix string, +) (string, error) { + if oidcConfig == nil || oidcConfig.UseDefault { + return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil + } + + // For custom claim templates, we need the repo and owner numeric IDs + ghCliImpl, ok := cli.(*ghCli) + if !ok { + // Fallback to default if we can't access the underlying CLI + return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil + } + runArgs := ghCliImpl.newRunArgs("api", "/repos/"+repoSlug, "--jq", "{id: .id, owner: {id: .owner.id}}") + res, err := ghCliImpl.run(ctx, runArgs) + if err != nil { + return "", fmt.Errorf("failed to get repository info for %s: %w", repoSlug, err) + } + var repoInfo gitHubRepoInfo + if err := json.Unmarshal([]byte(res.Stdout), &repoInfo); err != nil { + return "", fmt.Errorf("failed to parse repository info for %s: %w", repoSlug, err) + } + + // Build subject from claim keys + // The claim keys define the parts before the context (ref/pull_request). + // Example: include_claim_keys=["repository_owner_id", "repository_id"] produces + // "repository_owner_id:123:repository_id:456:ref:refs/heads/main" + var parts []string + for _, key := range oidcConfig.IncludeClaimKeys { + switch key { + case "repository_owner_id": + parts = append(parts, fmt.Sprintf("repository_owner_id:%d", repoInfo.Owner.ID)) + case "repository_id": + parts = append(parts, fmt.Sprintf("repository_id:%d", repoInfo.ID)) + case "repository_owner": + owner := strings.SplitN(repoSlug, "/", 2) + parts = append(parts, fmt.Sprintf("repository_owner:%s", owner[0])) + case "repository": + parts = append(parts, fmt.Sprintf("repository:%s", repoSlug)) + default: + // Unknown claim key — include it literally for forward compatibility + parts = append(parts, key) + } + } + parts = append(parts, suffix) + return strings.Join(parts, ":"), nil +} + +// GetOIDCSubjectForRepo queries the GitHub OIDC customization API for a repository. +// It first checks the repo-level customization, then falls back to the org-level customization. +// If no customization is found (or the API returns 404), it returns a config with UseDefault=true. +func (cli *ghCli) GetOIDCSubjectForRepo(ctx context.Context, repoSlug string) (*OIDCSubjectConfig, error) { + // Try repo-level first + runArgs := cli.newRunArgs("api", "/repos/"+repoSlug+"/actions/oidc/customization/sub") + res, err := cli.run(ctx, runArgs) + if err == nil { + var config OIDCSubjectConfig + if jsonErr := json.Unmarshal([]byte(res.Stdout), &config); jsonErr == nil && !config.UseDefault { + return &config, nil + } + } + + // Fall back to org-level + parts := strings.SplitN(repoSlug, "/", 2) + if len(parts) == 2 { + orgRunArgs := cli.newRunArgs("api", "/orgs/"+parts[0]+"/actions/oidc/customization/sub") + orgRes, orgErr := cli.run(ctx, orgRunArgs) + if orgErr == nil { + var config OIDCSubjectConfig + if jsonErr := json.Unmarshal([]byte(orgRes.Stdout), &config); jsonErr == nil && !config.UseDefault { + return &config, nil + } + } + } + + // Default: no customization + return &OIDCSubjectConfig{UseDefault: true}, nil +} + func (cli *ghCli) ForceConfigureAuth(authMode AuthTokenSource) { switch authMode { case TokenSourceFile: diff --git a/templates/common/infra/bicep/core/database/postgresql/flexibleserver.bicep b/templates/common/infra/bicep/core/database/postgresql/flexibleserver.bicep index 1aaa5842190..9d3eab52c34 100644 --- a/templates/common/infra/bicep/core/database/postgresql/flexibleserver.bicep +++ b/templates/common/infra/bicep/core/database/postgresql/flexibleserver.bicep @@ -7,11 +7,22 @@ param storage object param administratorLogin string @secure() param administratorLoginPassword string +param activeDirectoryAuth string = 'Disabled' param databaseNames array = [] param allowAzureIPsFirewall bool = false param allowAllIPsFirewall bool = false param allowedSingleIPs array = [] +param highAvailabilityMode string = 'Disabled' + +param backupRetentionDays int = 7 +param geoRedundantBackup string = 'Disabled' + +param maintenanceWindowCustomWindow string = 'Disabled' +param maintenanceWindowDayOfWeek int = 0 +param maintenanceWindowStartHour int = 0 +param maintenanceWindowStartMinute int = 0 + // PostgreSQL version param version string @@ -27,7 +38,21 @@ resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = administratorLoginPassword: administratorLoginPassword storage: storage highAvailability: { - mode: 'Disabled' + mode: highAvailabilityMode + } + authConfig: { + activeDirectoryAuth: activeDirectoryAuth + passwordAuth: (administratorLoginPassword == null) ? 'Disabled' : 'Enabled' + } + backup: { + backupRetentionDays: backupRetentionDays + geoRedundantBackup: geoRedundantBackup + } + maintenanceWindow: { + customWindow: maintenanceWindowCustomWindow + dayOfWeek: maintenanceWindowDayOfWeek + startHour: maintenanceWindowStartHour + startMinute: maintenanceWindowStartMinute } } @@ -38,24 +63,24 @@ resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { name: 'allow-all-IPs' properties: { - startIpAddress: '0.0.0.0' - endIpAddress: '255.255.255.255' + startIpAddress: '0.0.0.0' + endIpAddress: '255.255.255.255' } } resource firewall_azure 'firewallRules' = if (allowAzureIPsFirewall) { name: 'allow-all-azure-internal-IPs' properties: { - startIpAddress: '0.0.0.0' - endIpAddress: '0.0.0.0' + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' } } resource firewall_single 'firewallRules' = [for ip in allowedSingleIPs: { name: 'allow-single-${replace(ip, '.', '')}' properties: { - startIpAddress: ip - endIpAddress: ip + startIpAddress: ip + endIpAddress: ip } }] diff --git a/templates/common/infra/bicep/core/host/appservice.bicep b/templates/common/infra/bicep/core/host/appservice.bicep index c90c2491a2e..f720efd4e28 100644 --- a/templates/common/infra/bicep/core/host/appservice.bicep +++ b/templates/common/infra/bicep/core/host/appservice.bicep @@ -33,6 +33,7 @@ param numberOfWorkers int = -1 param scmDoBuildDuringDeployment bool = false param use32BitWorkerProcess bool = false param ftpsState string = 'FtpsOnly' +param httpLoggingEnabled bool = false param healthCheckPath string = '' resource appService 'Microsoft.Web/sites@2022-03-01' = { @@ -46,6 +47,7 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = { linuxFxVersion: linuxFxVersion alwaysOn: alwaysOn ftpsState: ftpsState + httpLoggingEnabled: httpLoggingEnabled appCommandLine: appCommandLine numberOfWorkers: numberOfWorkers != -1 ? numberOfWorkers : null minimumElasticInstanceCount: minimumElasticInstanceCount != -1 ? minimumElasticInstanceCount : null