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
34 changes: 34 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
35 changes: 35 additions & 0 deletions pkg/workflow/compiler_validators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
87 changes: 87 additions & 0 deletions pkg/workflow/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Comment on lines +557 to +566
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 {
Expand Down
2 changes: 2 additions & 0 deletions pkg/workflow/engine_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
55 changes: 55 additions & 0 deletions pkg/workflow/engine_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
25 changes: 25 additions & 0 deletions pkg/workflow/permissions_compiler_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}