diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 6d4a3c8890..54ea8bb679 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -10095,6 +10095,40 @@ "type": "string" } }, + "auth": { + "type": "object", + "description": "Engine-level authentication configuration for AWF API proxy sidecar integration (for example, Azure OpenAI via GitHub OIDC). Values are mapped to AWF_AUTH_* environment variables.", + "properties": { + "type": { + "type": "string", + "enum": ["github-oidc"], + "description": "Authentication type. Currently only 'github-oidc' is supported." + }, + "audience": { + "type": "string", + "description": "OIDC audience to request from GitHub Actions for token exchange.", + "format": "uri" + }, + "azure-tenant-id": { + "type": "string", + "description": "Optional Azure tenant ID for token exchange." + }, + "azure-client-id": { + "type": "string", + "description": "Optional Azure client ID for token exchange." + }, + "azure-scope": { + "type": "string", + "description": "Optional Azure OAuth scope (defaults to https://cognitiveservices.azure.com/.default in AWF sidecar)." + }, + "azure-cloud": { + "type": "string", + "description": "Optional Azure cloud name (for example, public, usgovernment, china)." + } + }, + "required": ["type"], + "additionalProperties": false + }, "config": { "type": "string", "description": "Additional TOML configuration text that will be appended to the generated config.toml in the action (codex engine only)" diff --git a/pkg/workflow/compiler_validators_test.go b/pkg/workflow/compiler_validators_test.go index 1093826b38..91c3406218 100644 --- a/pkg/workflow/compiler_validators_test.go +++ b/pkg/workflow/compiler_validators_test.go @@ -164,6 +164,41 @@ func TestValidatePermissions(t *testing.T) { shouldError: false, wantPermissions: true, }, + { + name: "engine auth github-oidc requires id-token write", + workflowData: &WorkflowData{ + Name: "Test", + MarkdownContent: "# Test", + AI: "copilot", + Permissions: "permissions:\n contents: read\n", + EngineConfig: &EngineConfig{ + ID: "copilot", + Auth: &EngineAuthConfig{ + Type: "github-oidc", + }, + }, + }, + shouldError: true, + errorContains: "engine.auth.type: github-oidc requires permissions.id-token: write", + wantPermissions: false, + }, + { + name: "engine auth github-oidc with id-token write succeeds", + workflowData: &WorkflowData{ + Name: "Test", + MarkdownContent: "# Test", + AI: "copilot", + Permissions: "permissions:\n contents: read\n id-token: write\n", + EngineConfig: &EngineConfig{ + ID: "copilot", + Auth: &EngineAuthConfig{ + Type: "github-oidc", + }, + }, + }, + shouldError: false, + wantPermissions: true, + }, } for _, tt := range tests { diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 12d3b4d2d3..77b4cd6166 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -27,6 +27,7 @@ type EngineConfig struct { Command string // Custom executable path (when set, skip installation steps) HarnessScript string // Custom Node.js harness script filename (replaces engine default harness script when supported) Env map[string]string + Auth *EngineAuthConfig // Engine-level auth config (mapped to AWF_AUTH_* env vars for API proxy sidecar auth) Config string Args []string Agent string // Agent identifier for copilot --agent flag (copilot engine only) @@ -58,6 +59,17 @@ type EngineConfig struct { Extensions []string } +// EngineAuthConfig represents engine.auth frontmatter settings that map to +// AWF_AUTH_* environment variables consumed by the AWF API proxy sidecar. +type EngineAuthConfig struct { + Type string + Audience string + AzureTenantID string + AzureClientID string + AzureScope string + AzureCloud string +} + // NetworkPermissions represents network access permissions for workflow execution // Controls which domains the workflow can access during execution. // @@ -308,6 +320,14 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng } } + // Extract optional 'auth' field (object) + if auth, hasAuth := engineObj["auth"]; hasAuth { + if authObj, ok := auth.(map[string]any); ok { + config.Auth = parseEngineAuthConfig(authObj) + applyEngineAuthEnv(config) + } + } + // Extract optional 'config' field (additional TOML configuration) if config_field, hasConfig := engineObj["config"]; hasConfig { if configStr, ok := config_field.(string); ok { @@ -499,6 +519,73 @@ func parseAuthDefinition(authObj map[string]any) *AuthDefinition { return def } +// parseEngineAuthConfig converts a raw engine.auth config map into EngineAuthConfig. +func parseEngineAuthConfig(authObj map[string]any) *EngineAuthConfig { + auth := &EngineAuthConfig{} + if s, ok := authObj["type"].(string); ok { + auth.Type = s + } + if s, ok := authObj["audience"].(string); ok { + auth.Audience = s + } + if s, ok := authObj["azure-tenant-id"].(string); ok { + auth.AzureTenantID = s + } + if s, ok := authObj["azure-client-id"].(string); ok { + auth.AzureClientID = s + } + if s, ok := authObj["azure-scope"].(string); ok { + auth.AzureScope = s + } + if s, ok := authObj["azure-cloud"].(string); ok { + auth.AzureCloud = s + } + return auth +} + +// applyEngineAuthEnv populates config.Env with AWF_AUTH_* environment variables +// derived from config.Auth. Existing config.Env values take precedence so users +// can explicitly override auth-derived values via engine.env. +func applyEngineAuthEnv(config *EngineConfig) { + if config == nil || config.Auth == nil { + return + } + if config.Env == nil { + config.Env = make(map[string]string) + } + + if config.Auth.Type != "" { + if _, exists := config.Env["AWF_AUTH_TYPE"]; !exists { + config.Env["AWF_AUTH_TYPE"] = config.Auth.Type + } + } + if config.Auth.Audience != "" { + if _, exists := config.Env["AWF_AUTH_OIDC_AUDIENCE"]; !exists { + config.Env["AWF_AUTH_OIDC_AUDIENCE"] = config.Auth.Audience + } + } + if config.Auth.AzureTenantID != "" { + if _, exists := config.Env["AWF_AUTH_AZURE_TENANT_ID"]; !exists { + config.Env["AWF_AUTH_AZURE_TENANT_ID"] = config.Auth.AzureTenantID + } + } + if config.Auth.AzureClientID != "" { + if _, exists := config.Env["AWF_AUTH_AZURE_CLIENT_ID"]; !exists { + config.Env["AWF_AUTH_AZURE_CLIENT_ID"] = config.Auth.AzureClientID + } + } + if config.Auth.AzureScope != "" { + if _, exists := config.Env["AWF_AUTH_AZURE_SCOPE"]; !exists { + config.Env["AWF_AUTH_AZURE_SCOPE"] = config.Auth.AzureScope + } + } + if config.Auth.AzureCloud != "" { + if _, exists := config.Env["AWF_AUTH_AZURE_CLOUD"]; !exists { + config.Env["AWF_AUTH_AZURE_CLOUD"] = config.Auth.AzureCloud + } + } +} + // parseRequestShape converts a raw request config map (from engine.provider.request) into // a RequestShape. func parseRequestShape(requestObj map[string]any) *RequestShape { diff --git a/pkg/workflow/engine_catalog_test.go b/pkg/workflow/engine_catalog_test.go index 8baf81707d..f78af28a05 100644 --- a/pkg/workflow/engine_catalog_test.go +++ b/pkg/workflow/engine_catalog_test.go @@ -112,6 +112,8 @@ func TestEngineCatalogMatchesSchema(t *testing.T) { require.True(t, ok, "second variant should have properties") assert.Contains(t, props1, "id", "second variant should have an 'id' property") + assert.Contains(t, props1, "auth", + "second variant should have an 'auth' property") idProp, ok := props1["id"].(map[string]any) require.True(t, ok, "id property should be a map") assert.Nil(t, idProp["enum"], diff --git a/pkg/workflow/engine_config_test.go b/pkg/workflow/engine_config_test.go index e696d19dcf..1d48b5e09d 100644 --- a/pkg/workflow/engine_config_test.go +++ b/pkg/workflow/engine_config_test.go @@ -330,6 +330,61 @@ func TestExtractEngineConfig(t *testing.T) { } } +func TestExtractEngineConfig_EngineAuthMapsToAWFEnv(t *testing.T) { + compiler := NewCompiler() + _, config := compiler.ExtractEngineConfig(map[string]any{ + "engine": map[string]any{ + "id": "copilot", + "auth": map[string]any{ + "type": "github-oidc", + "audience": "https://cognitiveservices.azure.com", + "azure-tenant-id": "tenant-id", + "azure-client-id": "client-id", + "azure-scope": "https://cognitiveservices.azure.com/.default", + "azure-cloud": "public", + }, + }, + }) + + assert.NotNil(t, config) + if assert.NotNil(t, config.Auth) { + assert.Equal(t, "github-oidc", config.Auth.Type) + assert.Equal(t, "https://cognitiveservices.azure.com", config.Auth.Audience) + assert.Equal(t, "tenant-id", config.Auth.AzureTenantID) + assert.Equal(t, "client-id", config.Auth.AzureClientID) + assert.Equal(t, "https://cognitiveservices.azure.com/.default", config.Auth.AzureScope) + assert.Equal(t, "public", config.Auth.AzureCloud) + } + + assert.Equal(t, "github-oidc", config.Env["AWF_AUTH_TYPE"]) + assert.Equal(t, "https://cognitiveservices.azure.com", config.Env["AWF_AUTH_OIDC_AUDIENCE"]) + assert.Equal(t, "tenant-id", config.Env["AWF_AUTH_AZURE_TENANT_ID"]) + assert.Equal(t, "client-id", config.Env["AWF_AUTH_AZURE_CLIENT_ID"]) + assert.Equal(t, "https://cognitiveservices.azure.com/.default", config.Env["AWF_AUTH_AZURE_SCOPE"]) + assert.Equal(t, "public", config.Env["AWF_AUTH_AZURE_CLOUD"]) +} + +func TestExtractEngineConfig_EngineEnvTakesPrecedenceOverEngineAuth(t *testing.T) { + compiler := NewCompiler() + _, config := compiler.ExtractEngineConfig(map[string]any{ + "engine": map[string]any{ + "id": "copilot", + "env": map[string]any{ + "AWF_AUTH_TYPE": "static", + "AWF_AUTH_OIDC_AUDIENCE": "from-engine-env", + }, + "auth": map[string]any{ + "type": "github-oidc", + "audience": "from-engine-auth", + }, + }, + }) + + assert.NotNil(t, config) + assert.Equal(t, "static", config.Env["AWF_AUTH_TYPE"]) + assert.Equal(t, "from-engine-env", config.Env["AWF_AUTH_OIDC_AUDIENCE"]) +} + func TestCompileWorkflowWithExtendedEngine(t *testing.T) { // Create temporary directory for test files tmpDir := testutil.TempDir(t, "extended-engine-test") diff --git a/pkg/workflow/permissions_compiler_validator.go b/pkg/workflow/permissions_compiler_validator.go index 601e896edb..03c7a7e2ac 100644 --- a/pkg/workflow/permissions_compiler_validator.go +++ b/pkg/workflow/permissions_compiler_validator.go @@ -134,6 +134,11 @@ func (c *Compiler) validatePermissions(workflowData *WorkflowData, markdownPath } } + // Enforce required id-token: write permission for engine.auth.type=github-oidc. + if err := validateEngineAuthPermissions(workflowData, workflowPermissions); err != nil { + return nil, formatCompilerError(markdownPath, "error", err.Error(), err) + } + // Emit warning if id-token: write permission is detected log.Printf("Checking for id-token: write permission") if level, exists := workflowPermissions.Get(PermissionIdToken); exists && level == PermissionWrite { @@ -146,3 +151,23 @@ Ensure proper audience validation and trust policies are configured.` return workflowPermissions, nil } + +func validateEngineAuthPermissions(workflowData *WorkflowData, workflowPermissions *Permissions) error { + if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.Auth == nil { + return nil + } + + if workflowData.EngineConfig.Auth.Type != "github-oidc" { + return nil + } + + if workflowPermissions == nil { + return fmt.Errorf("engine.auth.type: github-oidc requires permissions.id-token: write") + } + + if level, exists := workflowPermissions.Get(PermissionIdToken); !exists || level != PermissionWrite { + return fmt.Errorf("engine.auth.type: github-oidc requires permissions.id-token: write") + } + + return nil +}