From 65e865d1c6195120ca32192ff102eb4ef02efc7f Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 30 Sep 2026 12:25:25 +0200 Subject: [PATCH 1/7] feat: add per-resource auth mode to apps init Resources can be accessed on behalf of the user (obo), by the app's service principal (sp, the default), or both, driven by --auth-mode, --set ..authMode, or an interactive prompt. Reads the new manifest fields scope, appOnly, and plugin scopes, and passes the resulting scope union to the template as bundle.userApiScopes. Co-authored-by: Isaac --- .nextchanges/cli/apps-init-auth-mode.md | 1 + acceptance/apps/init/auth-mode/out.test.toml | 2 + acceptance/apps/init/auth-mode/output.txt | 248 ++++++++++++++++++ acceptance/apps/init/auth-mode/script | 23 ++ .../init/auth-mode/template/app.yaml.tmpl | 5 + .../auth-mode/template/appkit.plugins.json | 74 ++++++ .../auth-mode/template/databricks.yml.tmpl | 55 ++++ acceptance/apps/init/auth-mode/test.toml | 1 + cmd/apps/init.go | 204 +++++++++++++- cmd/apps/init_test.go | 83 ++++++ libs/apps/generator/generator.go | 95 ++++++- libs/apps/generator/generator_test.go | 61 +++++ libs/apps/manifest/manifest.go | 10 + libs/apps/prompt/prompt.go | 1 + 14 files changed, 852 insertions(+), 11 deletions(-) create mode 100644 .nextchanges/cli/apps-init-auth-mode.md create mode 100644 acceptance/apps/init/auth-mode/out.test.toml create mode 100644 acceptance/apps/init/auth-mode/output.txt create mode 100644 acceptance/apps/init/auth-mode/script create mode 100644 acceptance/apps/init/auth-mode/template/app.yaml.tmpl create mode 100644 acceptance/apps/init/auth-mode/template/appkit.plugins.json create mode 100644 acceptance/apps/init/auth-mode/template/databricks.yml.tmpl create mode 100644 acceptance/apps/init/auth-mode/test.toml diff --git a/.nextchanges/cli/apps-init-auth-mode.md b/.nextchanges/cli/apps-init-auth-mode.md new file mode 100644 index 00000000000..609b3f42ad7 --- /dev/null +++ b/.nextchanges/cli/apps-init-auth-mode.md @@ -0,0 +1 @@ +* Added `--auth-mode` and `--set ..authMode=obo|sp|both` to `databricks apps init` so AppKit resources can be accessed on behalf of the user, by the service principal, or both. The default stays service principal. diff --git a/acceptance/apps/init/auth-mode/out.test.toml b/acceptance/apps/init/auth-mode/out.test.toml new file mode 100644 index 00000000000..98ea5040486 --- /dev/null +++ b/acceptance/apps/init/auth-mode/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/apps/init/auth-mode/output.txt b/acceptance/apps/init/auth-mode/output.txt new file mode 100644 index 00000000000..a19f64d4cea --- /dev/null +++ b/acceptance/apps/init/auth-mode/output.txt @@ -0,0 +1,248 @@ + +=== Default: every resource bound to the service principal +>>> [CLI] apps init --template ./template --skip-install --name app-sp --features analytics,genie,jobs --set analytics.sql-warehouse.id=wh1 --set genie.genie-space.id=g1 --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k +✔ Creating project + +✔ Project created successfully! + + Location: [TEST_TMP_DIR]/app-sp + Files: 3 + +command: ['npm', 'run', 'start'] +env: + - name: DATABRICKS_WAREHOUSE_ID + valueFrom: sql-warehouse + - name: DATABRICKS_GENIE_SPACE_ID + valueFrom: genie-space + - name: DATABRICKS_JOB_ID + valueFrom: job + - name: SECRET_KEY + valueFrom: secret + - name: SECRET_SCOPE + valueFrom: secret +bundle: + name: app-sp + +variables: + sql_warehouse_id: + description: SQL Warehouse ID + genie_space_id: + description: Genie Space ID + genie_space_name: + description: Genie Space + job_id: + description: Job ID + secret_key: + description: Job token + secret_scope: + description: Job token + +resources: + apps: + app: + name: "app-sp" + description: "A Databricks App powered by AppKit" + source_code_path: ./ + + # Start the app automatically on deploy. + lifecycle: + started: true + user_api_scopes: + - dashboards.genie + + # The resources which this app has access to. + resources: + - name: sql-warehouse + sql_warehouse: + id: ${var.sql_warehouse_id} + permission: CAN_USE + - name: genie-space + genie_space: + name: ${var.genie_space_name} + space_id: ${var.genie_space_id} + permission: CAN_RUN + - name: job + job: + id: ${var.job_id} + permission: CAN_MANAGE_RUN + - name: secret + secret: + scope: ${var.secret_scope} + key: ${var.secret_key} + permission: READ + +targets: + default: + default: true + workspace: + host: [DATABRICKS_URL] + + variables: + sql_warehouse_id: wh1 + genie_space_id: g1 + job_id: 42 + secret_key: k + secret_scope: s + +=== App-wide OBO, job kept on the service principal +>>> [CLI] apps init --template ./template --skip-install --name app-obo --features analytics,genie,jobs --set analytics.sql-warehouse.id=wh1 --set genie.genie-space.id=g1 --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k --auth-mode obo --set jobs.job.authMode=sp +✔ Creating project + +✔ Project created successfully! + + Location: [TEST_TMP_DIR]/app-obo + Files: 3 + +command: ['npm', 'run', 'start'] +env: + - name: DATABRICKS_WAREHOUSE_ID + value: wh1 + - name: DATABRICKS_GENIE_SPACE_ID + value: g1 + - name: DATABRICKS_JOB_ID + valueFrom: job + - name: SECRET_KEY + valueFrom: secret + - name: SECRET_SCOPE + valueFrom: secret +bundle: + name: app-obo + +variables: + job_id: + description: Job ID + secret_key: + description: Job token + secret_scope: + description: Job token + +resources: + apps: + app: + name: "app-obo" + description: "A Databricks App powered by AppKit" + source_code_path: ./ + + # Start the app automatically on deploy. + lifecycle: + started: true + user_api_scopes: + - sql + - genie + - ai-gateway + + # The resources which this app has access to. + resources: + - name: job + job: + id: ${var.job_id} + permission: CAN_MANAGE_RUN + - name: secret + secret: + scope: ${var.secret_scope} + key: ${var.secret_key} + permission: READ + +targets: + default: + default: true + workspace: + host: [DATABRICKS_URL] + + variables: + job_id: 42 + secret_key: k + secret_scope: s + +=== Mixed: warehouse both, genie OBO +>>> [CLI] apps init --template ./template --skip-install --name app-mixed --features analytics,genie,jobs --set analytics.sql-warehouse.id=wh1 --set genie.genie-space.id=g1 --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k --set analytics.sql-warehouse.authMode=both --set genie.genie-space.authMode=obo +✔ Creating project + +✔ Project created successfully! + + Location: [TEST_TMP_DIR]/app-mixed + Files: 3 + +command: ['npm', 'run', 'start'] +env: + - name: DATABRICKS_WAREHOUSE_ID + valueFrom: sql-warehouse + - name: DATABRICKS_GENIE_SPACE_ID + value: g1 + - name: DATABRICKS_JOB_ID + valueFrom: job + - name: SECRET_KEY + valueFrom: secret + - name: SECRET_SCOPE + valueFrom: secret +bundle: + name: app-mixed + +variables: + sql_warehouse_id: + description: SQL Warehouse ID + job_id: + description: Job ID + secret_key: + description: Job token + secret_scope: + description: Job token + +resources: + apps: + app: + name: "app-mixed" + description: "A Databricks App powered by AppKit" + source_code_path: ./ + + # Start the app automatically on deploy. + lifecycle: + started: true + user_api_scopes: + - sql + - genie + - ai-gateway + + # The resources which this app has access to. + resources: + - name: sql-warehouse + sql_warehouse: + id: ${var.sql_warehouse_id} + permission: CAN_USE + - name: job + job: + id: ${var.job_id} + permission: CAN_MANAGE_RUN + - name: secret + secret: + scope: ${var.secret_scope} + key: ${var.secret_key} + permission: READ + +targets: + default: + default: true + workspace: + host: [DATABRICKS_URL] + + variables: + sql_warehouse_id: wh1 + job_id: 42 + secret_key: k + secret_scope: s + +=== Errors +>>> errcode [CLI] apps init --template ./template --skip-install --name app-err --features jobs --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k --auth-mode obo +Error: resource "job" of plugin "jobs" cannot be accessed on behalf of the user; use --set jobs.job.authMode=sp + +Exit code: 1 + +>>> errcode [CLI] apps init --template ./template --skip-install --name app-err --features jobs --set jobs.secret.authMode=sp +Error: resource "secret" of plugin "jobs" is always accessed by the app's service principal; remove --set jobs.secret.authMode=sp + +Exit code: 1 + +>>> errcode [CLI] apps init --template ./template --skip-install --name app-err --features jobs --auth-mode both +Error: invalid --auth-mode value: "both" (must be obo or sp; use --set ..authMode=both for a single resource) + +Exit code: 1 diff --git a/acceptance/apps/init/auth-mode/script b/acceptance/apps/init/auth-mode/script new file mode 100644 index 00000000000..a99cbdb572a --- /dev/null +++ b/acceptance/apps/init/auth-mode/script @@ -0,0 +1,23 @@ +init() { + name=$1; shift + trace $CLI apps init --template ./template --skip-install --name $name \ + --features analytics,genie,jobs \ + --set analytics.sql-warehouse.id=wh1 --set genie.genie-space.id=g1 \ + --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k "$@" + cat $name/app.yaml $name/databricks.yml + rm -rf $name +} + +title "Default: every resource bound to the service principal" +init app-sp + +title "App-wide OBO, job kept on the service principal" +init app-obo --auth-mode obo --set jobs.job.authMode=sp + +title "Mixed: warehouse both, genie OBO" +init app-mixed --set analytics.sql-warehouse.authMode=both --set genie.genie-space.authMode=obo + +title "Errors" +trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k --auth-mode obo +trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --set jobs.secret.authMode=sp +trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --auth-mode both diff --git a/acceptance/apps/init/auth-mode/template/app.yaml.tmpl b/acceptance/apps/init/auth-mode/template/app.yaml.tmpl new file mode 100644 index 00000000000..66311d1213e --- /dev/null +++ b/acceptance/apps/init/auth-mode/template/app.yaml.tmpl @@ -0,0 +1,5 @@ +command: ['npm', 'run', 'start'] +{{- if .appEnv}} +env: +{{.appEnv}} +{{- end}} diff --git a/acceptance/apps/init/auth-mode/template/appkit.plugins.json b/acceptance/apps/init/auth-mode/template/appkit.plugins.json new file mode 100644 index 00000000000..3e7899ea703 --- /dev/null +++ b/acceptance/apps/init/auth-mode/template/appkit.plugins.json @@ -0,0 +1,74 @@ +{ + "version": "1.0", + "plugins": { + "analytics": { + "name": "analytics", + "displayName": "Analytics", + "description": "SQL analytics", + "package": "@databricks/appkit", + "scopes": ["ai-gateway"], + "resources": { + "required": [ + { + "type": "sql_warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", + "description": "SQL Warehouse for analytics queries", + "permission": "CAN_USE", + "scope": "sql", + "fields": {"id": {"env": "DATABRICKS_WAREHOUSE_ID", "description": "SQL Warehouse ID"}} + } + ], + "optional": [] + } + }, + "genie": { + "name": "genie", + "displayName": "Genie", + "description": "Genie spaces", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "genie_space", + "alias": "Genie Space", + "resourceKey": "genie-space", + "description": "Genie Space", + "permission": "CAN_RUN", + "scope": "genie", + "fields": {"id": {"env": "DATABRICKS_GENIE_SPACE_ID", "description": "Genie Space ID"}} + } + ], + "optional": [] + } + }, + "jobs": { + "name": "jobs", + "displayName": "Jobs", + "description": "Lakeflow jobs", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "job", + "alias": "Job", + "resourceKey": "job", + "description": "Job to trigger", + "permission": "CAN_MANAGE_RUN", + "fields": {"id": {"env": "DATABRICKS_JOB_ID", "description": "Job ID"}} + }, + { + "type": "secret", + "alias": "Secret", + "resourceKey": "secret", + "description": "Job token", + "permission": "READ", + "appOnly": true, + "fields": {"scope": {"env": "SECRET_SCOPE"}, "key": {"env": "SECRET_KEY"}} + } + ], + "optional": [] + } + } + } +} diff --git a/acceptance/apps/init/auth-mode/template/databricks.yml.tmpl b/acceptance/apps/init/auth-mode/template/databricks.yml.tmpl new file mode 100644 index 00000000000..6a749066692 --- /dev/null +++ b/acceptance/apps/init/auth-mode/template/databricks.yml.tmpl @@ -0,0 +1,55 @@ +bundle: + name: {{.projectName}} +{{- if .bundle.variables}} + +variables: +{{.bundle.variables}} +{{- end}} + +resources: + apps: + app: + name: "{{.projectName}}" + description: "{{.appDescription}}" + source_code_path: ./ + + # Start the app automatically on deploy. + lifecycle: + started: true + +{{- if .bundle.userApiScopes}} + user_api_scopes: +{{.bundle.userApiScopes}} +{{- else if or .plugins.genie .plugins.files .plugins.serving}} + user_api_scopes: +{{- if .plugins.genie}} + - dashboards.genie +{{- end}} +{{- if .plugins.files}} + - files.files +{{- end}} +{{- if .plugins.serving}} + - serving.serving-endpoints +{{- end}} +{{- else}} + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files, serving.serving-endpoints + # user_api_scopes: + # - sql +{{- end}} +{{- if .bundle.resources}} + + # The resources which this app has access to. + resources: +{{.bundle.resources}} +{{- end}} + +targets: + default: + default: true + workspace: + host: {{.workspaceHost}} +{{- if .bundle.targetVariables}} + + variables: +{{.bundle.targetVariables}} +{{- end}} diff --git a/acceptance/apps/init/auth-mode/test.toml b/acceptance/apps/init/auth-mode/test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/apps/init/auth-mode/test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 105442387cd..47d713273c2 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -53,6 +53,10 @@ const ( // are not provided upfront. When set to "true", --set requirement and // resource validation are skipped. agenticModeEnvVar = "DATABRICKS_APPS_AGENTIC_MODE" + + // authModeField is the reserved --set field that selects a resource's auth mode + // (e.g., --set analytics.sql-warehouse.authMode=obo). + authModeField = "authMode" ) // normalizeVersion converts a version string to the template tag format "template-vX.X.X". @@ -92,6 +96,7 @@ func newInitCmd() *cobra.Command { setValues []string autoApprove bool skipInstall bool + authMode string ) cmd := &cobra.Command{ @@ -133,6 +138,11 @@ Examples: --set analytics.sql-warehouse.id=wh1 \ --set reporting.sql-warehouse.id=wh2 + # Access resources on behalf of the user, except the job + databricks apps init --name my-app --features=analytics,jobs --auth-mode obo \ + --set jobs.job.authMode=sp \ + --set analytics.sql-warehouse.id=abc123 --set jobs.job.id=42 + # Create, deploy, and run with dev-remote databricks apps init --name my-app --deploy --run=dev-remote @@ -146,6 +156,8 @@ Resource configuration (--set): Set resource values using --set plugin.resourceKey.field=value Keys are defined in the template's appkit.plugins.json manifest. Multi-field resources (e.g., database, secret) require all fields to be set together. + Use --set plugin.resourceKey.authMode=obo|sp|both to choose how the app accesses a resource: + on behalf of the user (obo), as the app's service principal (sp), or both. Environment variables: DATABRICKS_APPKIT_TEMPLATE_PATH Override the default template source`, @@ -177,6 +189,7 @@ Environment variables: setValues: setValues, autoApprove: autoApprove, skipInstall: skipInstall, + authMode: authMode, }) }, } @@ -196,6 +209,7 @@ Environment variables: cmd.Flags().BoolVar(&deploy, "deploy", false, "Deploy the app after creation") cmd.Flags().StringVar(&run, "run", "", "Run the app after creation (none, dev, dev-remote)") cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip confirmation prompts for optional resources. Optional resources are only configured when their values are provided via --set.") + cmd.Flags().StringVar(&authMode, "auth-mode", "", "Default resource auth mode: obo (on behalf of the user) or sp (service principal). Prompts if not provided in interactive mode, otherwise sp.") cmd.Flags().BoolVar(&skipInstall, "skip-install", false, "Skip installing project dependencies (e.g. npm install / uv sync). Cannot be combined with --run.") return cmd @@ -219,6 +233,7 @@ type createOptions struct { setValues []string // --set plugin.resourceKey.field=value pairs autoApprove bool skipInstall bool + authMode string // --auth-mode default for all resources } // parseSetValues parses --set key=value pairs into the resourceValues map. @@ -236,6 +251,9 @@ func parseSetValues(setValues []string, m *manifest.Manifest) (map[string]string return nil, fmt.Errorf("invalid --set key %q, expected plugin.resourceKey.field", key) } pluginName, resourceKey, fieldName := parts[0], parts[1], parts[2] + if fieldName == authModeField { + continue + } plugin := m.GetPluginByName(pluginName) if plugin == nil { @@ -286,6 +304,72 @@ func parseSetValues(setValues []string, m *manifest.Manifest) (map[string]string return rv, nil } +// parseSetAuthModes parses --set plugin.resourceKey.authMode=value pairs into a map of resource key to auth mode. +// Other --set pairs are ignored (see parseSetValues). +func parseSetAuthModes(setValues []string, m *manifest.Manifest) (map[string]string, error) { + modes := make(map[string]string) + for _, sv := range setValues { + key, value, _ := strings.Cut(sv, "=") + parts := strings.SplitN(key, ".", 3) + if len(parts) != 3 || parts[2] != authModeField { + continue + } + pluginName, resourceKey := parts[0], parts[1] + + plugin := m.GetPluginByName(pluginName) + if plugin == nil { + return nil, fmt.Errorf("unknown plugin %q in --set %q; available: %v", pluginName, sv, m.GetPluginNames()) + } + all := append(plugin.Resources.Required, plugin.Resources.Optional...) + idx := slices.IndexFunc(all, func(r manifest.Resource) bool { return r.Key() == resourceKey }) + if idx < 0 { + return nil, fmt.Errorf("plugin %q has no resource with key %q", pluginName, resourceKey) + } + r := all[idx] + + switch value { + case generator.AuthModeSP, generator.AuthModeOBO, generator.AuthModeBoth: + default: + return nil, fmt.Errorf("invalid auth mode %q in --set %q (must be obo, sp, or both)", value, sv) + } + if r.AppOnly { + return nil, fmt.Errorf("resource %q of plugin %q is always accessed by the app's service principal; remove --set %s", resourceKey, pluginName, sv) + } + if value != generator.AuthModeSP && r.Scope == "" { + return nil, fmt.Errorf("resource %q of plugin %q cannot be accessed on behalf of the user; use %s.%s.%s=sp", resourceKey, pluginName, pluginName, resourceKey, authModeField) + } + modes[resourceKey] = value + } + return modes, nil +} + +// resolveAuthModes returns the auth mode of each resource. Precedence, highest first: +// --set authMode, the prompted choice, the --auth-mode default, then sp. +// App-only resources and resources without an entry resolve to sp. +func resolveAuthModes(resources []manifest.Resource, setModes, promptedModes map[string]string, defaultMode string) (map[string]string, error) { + modes := make(map[string]string) + for _, r := range resources { + if r.AppOnly { + continue + } + mode, ok := setModes[r.Key()] + if !ok { + mode, ok = promptedModes[r.Key()] + } + if !ok { + mode = defaultMode + } + if mode == "" || mode == generator.AuthModeSP { + continue + } + if r.Scope == "" { + return nil, fmt.Errorf("resource %q of plugin %q cannot be accessed on behalf of the user; use --set %s.%s.%s=sp", r.Key(), r.PluginName, r.PluginName, r.Key(), authModeField) + } + modes[r.Key()] = mode + } + return modes, nil +} + // pluginHasResourceField checks whether a plugin declares a resource with the given key and field name. func pluginHasResourceField(p *manifest.Plugin, resourceKey, fieldName string) bool { for _, r := range append(p.Resources.Required, p.Resources.Optional...) { @@ -325,6 +409,7 @@ type tmplBundle struct { Variables string Resources string TargetVariables string + UserAPIScopes string } // dotEnvVars holds the generated .env file content. @@ -382,7 +467,8 @@ func parseDeployAndRunFlags(deploy bool, run string) (bool, prompt.RunMode, erro // promptForPluginsAndDeps prompts for plugins and their resource dependencies using the manifest. // skipDeployRunPrompt indicates whether to skip prompting for deploy/run (because flags were provided). -func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelectedPlugins []string, skipDeployRunPrompt, autoApprove bool) (*prompt.CreateProjectConfig, error) { +// promptAuthMode indicates whether to prompt for how the app accesses resources (--auth-mode was not provided). +func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelectedPlugins []string, skipDeployRunPrompt, autoApprove, promptAuthMode bool) (*prompt.CreateProjectConfig, error) { config := &prompt.CreateProjectConfig{ Dependencies: make(map[string]string), Features: preSelectedPlugins, // Reuse Features field for plugin names @@ -455,6 +541,24 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec } } + // Step 3b: Auth mode for resources that can be accessed on behalf of the user. + if promptAuthMode { + var configured []manifest.Resource + for _, r := range append(resources, optionalResources...) { + for k := range config.Dependencies { + if strings.HasPrefix(k, r.Key()+".") { + configured = append(configured, r) + break + } + } + } + modes, err := promptForAuthModes(ctx, configured, theme) + if err != nil { + return nil, err + } + config.AuthModes = modes + } + // Step 4: Description config.Description = prompt.DefaultAppDescription err := huh.NewInput(). @@ -482,6 +586,79 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec return config, nil } +// promptForAuthModes asks once how the app accesses its resources and, for "Mixed", +// asks per resource that can be accessed on behalf of the user. +// Resources that cannot are listed as info and resolve to sp. +// Returns a map of resource key to auth mode, or nil if nothing needs to be asked. +func promptForAuthModes(ctx context.Context, resources []manifest.Resource, theme *huh.Theme) (map[string]string, error) { + var capable []manifest.Resource + for _, r := range resources { + if r.Scope != "" && !r.AppOnly { + capable = append(capable, r) + } + } + if len(capable) == 0 { + return nil, nil + } + + const mixed = "mixed" + appMode := generator.AuthModeSP + err := huh.NewSelect[string](). + Title("How should the app access resources?"). + Options( + huh.NewOption("Service principal", generator.AuthModeSP), + huh.NewOption("On behalf of user", generator.AuthModeOBO), + huh.NewOption("Mixed (choose per resource)", mixed), + ). + Value(&appMode). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + + modes := make(map[string]string, len(capable)) + for _, r := range capable { + mode := appMode + if appMode == mixed { + mode = generator.AuthModeSP + err := huh.NewSelect[string](). + Title(fmt.Sprintf("How should the app access %s?", r.Alias)). + Options( + huh.NewOption("On behalf of user", generator.AuthModeOBO), + huh.NewOption("Service principal", generator.AuthModeSP), + huh.NewOption("Both", generator.AuthModeBoth), + ). + Value(&mode). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + } + modes[r.Key()] = mode + prompt.PrintAnswered(ctx, r.Alias, authModeLabel(mode)) + } + for _, r := range resources { + if r.Scope == "" || r.AppOnly { + prompt.PrintAnswered(ctx, r.Alias, authModeLabel(generator.AuthModeSP)+" (only option)") + } + } + return modes, nil +} + +// authModeLabel returns a user-facing label for an auth mode. +func authModeLabel(mode string) string { + switch mode { + case generator.AuthModeOBO: + return "on behalf of user" + case generator.AuthModeBoth: + return "service principal and on behalf of user" + default: + return "service principal" + } +} + // promptForResource prompts the user for a resource value. // Returns a map of value keys to values. For single-field resources the key is "resource_key.field". // For multi-field resources, keys use "resource_key.field_name". @@ -981,8 +1158,15 @@ func runCreate(ctx context.Context, opts createOptions) error { return errors.New("--skip-install cannot be combined with --run (dev/dev-remote require dependencies to be installed)") } + switch opts.authMode { + case "", generator.AuthModeSP, generator.AuthModeOBO: + default: + return fmt.Errorf("invalid --auth-mode value: %q (must be obo or sp; use --set ..authMode=both for a single resource)", opts.authMode) + } + var selectedPlugins []string var resourceValues map[string]string + var promptedAuthModes map[string]string var shouldDeploy bool var runMode prompt.RunMode isInteractive := cmdio.IsPromptSupported(ctx) @@ -1225,12 +1409,13 @@ func runCreate(ctx context.Context, opts createOptions) error { } } else if isInteractive && !opts.pluginsChanged && !flagsMode { // Interactive mode without --plugins flag: prompt for plugins, dependencies, description - config, err := promptForPluginsAndDeps(ctx, m, selectedPlugins, skipDeployRunPrompt, opts.autoApprove) + config, err := promptForPluginsAndDeps(ctx, m, selectedPlugins, skipDeployRunPrompt, opts.autoApprove, opts.authMode == "") if err != nil { return err } selectedPlugins = config.Features // Features field holds plugin names resourceValues = config.Dependencies + promptedAuthModes = config.AuthModes if config.Description != "" { opts.description = config.Description } @@ -1278,9 +1463,21 @@ func runCreate(ctx context.Context, opts createOptions) error { maps.Copy(resourceValues, setVals) } + setAuthModes, err := parseSetAuthModes(opts.setValues, m) + if err != nil { + return err + } + // Always include mandatory plugins regardless of user selection or flags. selectedPlugins = appendUnique(selectedPlugins, m.GetMandatoryPluginNames()...) + authModes, err := resolveAuthModes( + append(m.CollectResources(selectedPlugins), m.CollectOptionalResources(selectedPlugins)...), + setAuthModes, promptedAuthModes, opts.authMode) + if err != nil { + return err + } + // Warn when --features adds plugins that the pre-rendered template // cannot inject (its server.ts and app.yaml are already finalised). if skipPluginSelection && opts.pluginsChanged { @@ -1383,6 +1580,7 @@ func runCreate(ctx context.Context, opts createOptions) error { WorkspaceHost: workspaceHost, Profile: profile, ResourceValues: resourceValues, + AuthModes: authModes, } // Generate configurations from selected plugins @@ -1413,6 +1611,7 @@ func runCreate(ctx context.Context, opts createOptions) error { Variables: bundleVars, Resources: bundleRes, TargetVariables: targetVars, + UserAPIScopes: generator.GenerateUserAPIScopes(selectedPluginList, genConfig), }, DotEnv: dotEnvVars{ Content: generator.GenerateDotEnv(selectedPluginList, genConfig), @@ -1873,6 +2072,7 @@ func templateData(vars templateVars) map[string]any { "variables": vars.Bundle.Variables, "resources": vars.Bundle.Resources, "targetVariables": vars.Bundle.TargetVariables, + "userApiScopes": vars.Bundle.UserAPIScopes, }, "dotEnv": map[string]any{ "content": vars.DotEnv.Content, diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 6d496dbcfe2..9f9179904d0 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -1457,3 +1457,86 @@ func TestSetFirstAppNameNoResources(t *testing.T) { require.NotNil(t, bundleName) assert.Equal(t, "myapp", bundleName.Value) } + +func authModeManifest() *manifest.Manifest { + return &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", ResourceKey: "sql-warehouse", Scope: "sql", PluginName: "analytics", Fields: map[string]manifest.ResourceField{"id": {}}}, + {Type: "secret", ResourceKey: "secret", AppOnly: true, PluginName: "analytics", Fields: map[string]manifest.ResourceField{"scope": {}, "key": {}}}, + }, + }, + }, + "jobs": { + Name: "jobs", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "job", ResourceKey: "job", PluginName: "jobs", Fields: map[string]manifest.ResourceField{"id": {}}}, + }, + }, + }, + }, + } +} + +func TestParseSetAuthModes(t *testing.T) { + m := authModeManifest() + tests := []struct { + name string + setValues []string + want map[string]string + wantErr string + }{ + {name: "values ignored", setValues: []string{"analytics.sql-warehouse.id=wh"}, want: map[string]string{}}, + {name: "obo", setValues: []string{"analytics.sql-warehouse.authMode=obo"}, want: map[string]string{"sql-warehouse": "obo"}}, + {name: "both", setValues: []string{"analytics.sql-warehouse.authMode=both"}, want: map[string]string{"sql-warehouse": "both"}}, + {name: "sp without scope", setValues: []string{"jobs.job.authMode=sp"}, want: map[string]string{"job": "sp"}}, + {name: "invalid value", setValues: []string{"analytics.sql-warehouse.authMode=user"}, wantErr: "invalid auth mode"}, + {name: "unknown resource", setValues: []string{"analytics.nope.authMode=obo"}, wantErr: "no resource with key"}, + {name: "app only", setValues: []string{"analytics.secret.authMode=sp"}, wantErr: "always accessed by the app's service principal"}, + {name: "obo without scope", setValues: []string{"jobs.job.authMode=obo"}, wantErr: "cannot be accessed on behalf of the user"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSetAuthModes(tt.setValues, m) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + + // parseSetValues must accept and skip the authMode pseudo-field. + rv, err := parseSetValues([]string{"analytics.sql-warehouse.authMode=obo", "analytics.sql-warehouse.id=wh"}, m) + require.NoError(t, err) + assert.Equal(t, map[string]string{"sql-warehouse.id": "wh"}, rv) +} + +func TestResolveAuthModes(t *testing.T) { + m := authModeManifest() + resources := m.CollectResources([]string{"analytics", "jobs"}) + + got, err := resolveAuthModes(resources, nil, nil, "") + require.NoError(t, err) + assert.Empty(t, got) + + got, err = resolveAuthModes(resources, map[string]string{"job": "sp"}, nil, "obo") + require.NoError(t, err) + assert.Equal(t, map[string]string{"sql-warehouse": "obo"}, got) + + _, err = resolveAuthModes(resources, nil, nil, "obo") + assert.ErrorContains(t, err, "use --set jobs.job.authMode=sp") + + got, err = resolveAuthModes(resources, map[string]string{"sql-warehouse": "both"}, map[string]string{"sql-warehouse": "obo"}, "") + require.NoError(t, err) + assert.Equal(t, map[string]string{"sql-warehouse": "both"}, got) + + got, err = resolveAuthModes(resources, nil, map[string]string{"sql-warehouse": "obo"}, "") + require.NoError(t, err) + assert.Equal(t, map[string]string{"sql-warehouse": "obo"}, got) +} diff --git a/libs/apps/generator/generator.go b/libs/apps/generator/generator.go index 9d8b2f3d9f1..7c17fdb9c8e 100644 --- a/libs/apps/generator/generator.go +++ b/libs/apps/generator/generator.go @@ -40,6 +40,30 @@ type Config struct { // ResourceValues maps resource value keys to values. // Keys use "resource_key.field_name" format (e.g., "sql-warehouse.id" -> "abc123"). ResourceValues map[string]string + // AuthModes maps resource keys to their auth mode (AuthModeSP, AuthModeOBO, or AuthModeBoth). + // Resources without an entry use AuthModeSP. + AuthModes map[string]string +} + +// Auth modes describe how the app accesses a resource. +const ( + // AuthModeSP binds the resource to the app's service principal. + AuthModeSP = "sp" + // AuthModeOBO injects the resource id and accesses it on behalf of the user. + AuthModeOBO = "obo" + // AuthModeBoth binds the resource to the service principal and also grants the user scope. + AuthModeBoth = "both" +) + +// isOBOOnly returns true if the resource is accessed only on behalf of the user (not bound to the service principal). +func isOBOOnly(r manifest.Resource, cfg Config) bool { + return cfg.AuthModes[r.Key()] == AuthModeOBO +} + +// needsScope returns true if the resource is accessed on behalf of the user. +func needsScope(r manifest.Resource, cfg Config) bool { + mode := cfg.AuthModes[r.Key()] + return mode == AuthModeOBO || mode == AuthModeBoth } // hasResourceValues returns true if any value exists in cfg for the given resource. @@ -60,10 +84,12 @@ func GenerateBundleVariables(plugins []manifest.Plugin, cfg Config) string { for _, p := range plugins { for _, r := range p.Resources.Required { - lines = append(lines, generateVariableLines(r)...) + if !isOBOOnly(r, cfg) { + lines = append(lines, generateVariableLines(r)...) + } } for _, r := range p.Resources.Optional { - if hasResourceValues(r, cfg) { + if hasResourceValues(r, cfg) && !isOBOOnly(r, cfg) { lines = append(lines, generateVariableLines(r)...) } } @@ -101,6 +127,9 @@ func GenerateBundleResources(plugins []manifest.Plugin, cfg Config) string { for _, p := range plugins { // Required resources for _, r := range p.Resources.Required { + if isOBOOnly(r, cfg) { + continue + } resource := generateResourceYAML(r, 8) if resource != "" { blocks = append(blocks, resource) @@ -108,7 +137,7 @@ func GenerateBundleResources(plugins []manifest.Plugin, cfg Config) string { } // Optional resources (only if value provided) for _, r := range p.Resources.Optional { - if hasResourceValues(r, cfg) { + if hasResourceValues(r, cfg) && !isOBOOnly(r, cfg) { resource := generateResourceYAML(r, 8) if resource != "" { blocks = append(blocks, resource) @@ -128,10 +157,12 @@ func GenerateTargetVariables(plugins []manifest.Plugin, cfg Config) string { for _, p := range plugins { for _, r := range p.Resources.Required { - lines = append(lines, generateTargetVarLines(r, cfg)...) + if !isOBOOnly(r, cfg) { + lines = append(lines, generateTargetVarLines(r, cfg)...) + } } for _, r := range p.Resources.Optional { - if hasResourceValues(r, cfg) { + if hasResourceValues(r, cfg) && !isOBOOnly(r, cfg) { lines = append(lines, generateTargetVarLines(r, cfg)...) } } @@ -231,17 +262,18 @@ func GenerateDotEnvExample(plugins []manifest.Plugin) string { // GenerateAppEnv generates the env entries for app.yaml. // Each resource field with an Env mapping produces a YAML list entry with // name (the env var) and valueFrom (the resource key in databricks.yml resources). +// Resources accessed only on behalf of the user get a literal value instead of valueFrom. // Includes both required resources and optional resources that have values. func GenerateAppEnv(plugins []manifest.Plugin, cfg Config) string { var lines []string for _, p := range plugins { for _, r := range p.Resources.Required { - lines = append(lines, appEnvLines(r)...) + lines = append(lines, appEnvLines(r, cfg)...) } for _, r := range p.Resources.Optional { if hasResourceValues(r, cfg) { - lines = append(lines, appEnvLines(r)...) + lines = append(lines, appEnvLines(r, cfg)...) } } } @@ -250,8 +282,9 @@ func GenerateAppEnv(plugins []manifest.Plugin, cfg Config) string { } // appEnvLines returns app.yaml env entries for a resource. -// Each field with an Env produces "- name: \n valueFrom: ". -func appEnvLines(r manifest.Resource) []string { +// Each field with an Env produces "- name: \n valueFrom: ", +// or "- name: \n value: " when the resource is accessed only on behalf of the user. +func appEnvLines(r manifest.Resource, cfg Config) []string { var lines []string for _, fieldName := range r.FieldNames() { field := r.Fields[fieldName] @@ -261,6 +294,13 @@ func appEnvLines(r manifest.Resource) []string { if field.LocalOnly { continue } + if isOBOOnly(r, cfg) { + lines = append(lines, + " - name: "+field.Env, + " value: "+quoteYAMLValue(cfg.ResourceValues[r.Key()+"."+fieldName]), + ) + continue + } lines = append(lines, " - name: "+field.Env, " valueFrom: "+r.Key(), @@ -269,6 +309,43 @@ func appEnvLines(r manifest.Resource) []string { return lines } +// GenerateUserAPIScopes generates the user_api_scopes entries for databricks.yml. +// Output is indented with 8 spaces for insertion under "user_api_scopes:". +// Returns "" unless at least one resource is accessed on behalf of the user. The scopes are +// those of the resources accessed on behalf of the user, followed by the plugins' capability +// scopes, deduplicated in first-seen order. +func GenerateUserAPIScopes(plugins []manifest.Plugin, cfg Config) string { + var scopes []string + for _, p := range plugins { + for _, r := range p.Resources.Required { + if needsScope(r, cfg) { + scopes = append(scopes, r.Scope) + } + } + for _, r := range p.Resources.Optional { + if hasResourceValues(r, cfg) && needsScope(r, cfg) { + scopes = append(scopes, r.Scope) + } + } + } + if len(scopes) == 0 { + return "" + } + for _, p := range plugins { + scopes = append(scopes, p.Scopes...) + } + + var lines []string + seen := make(map[string]bool) + for _, s := range scopes { + if !seen[s] { + seen[s] = true + lines = append(lines, " - "+s) + } + } + return strings.Join(lines, "\n") +} + // appResourceSpec defines how a manifest resource type maps to DABs AppResource YAML. type appResourceSpec struct { yamlKey string // DABs YAML key under the resource entry (e.g., "sql_warehouse", "uc_securable") diff --git a/libs/apps/generator/generator_test.go b/libs/apps/generator/generator_test.go index ca8b19b82ac..294b9873e51 100644 --- a/libs/apps/generator/generator_test.go +++ b/libs/apps/generator/generator_test.go @@ -1147,3 +1147,64 @@ func TestDedupeResourcesRequiredWinsOverOptional(t *testing.T) { res := generator.GenerateBundleResources(deduped, generator.Config{}) assert.Equal(t, 1, strings.Count(res, "- name: postgres")) } + +func authModePlugins() []manifest.Plugin { + return []manifest.Plugin{ + { + Name: "analytics", + Scopes: []string{"ai-gateway", "sql"}, + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "sql_warehouse", ResourceKey: "sql-warehouse", Scope: "sql", + Fields: map[string]manifest.ResourceField{"id": {Env: "DATABRICKS_WAREHOUSE_ID"}}, + }, + { + Type: "genie_space", ResourceKey: "genie-space", Scope: "genie", + Fields: map[string]manifest.ResourceField{"id": {Env: "GENIE_SPACE_ID"}}, + }, + }, + }, + }, + } +} + +func authModeConfig(modes map[string]string) generator.Config { + return generator.Config{ + ResourceValues: map[string]string{"sql-warehouse.id": "wh1", "genie-space.id": "g1", "genie-space.name": "space"}, + AuthModes: modes, + } +} + +func TestAuthModeSPIsDefault(t *testing.T) { + plugins := authModePlugins() + sp := authModeConfig(map[string]string{"sql-warehouse": generator.AuthModeSP}) + unset := authModeConfig(nil) + + assert.Equal(t, generator.GenerateBundleResources(plugins, unset), generator.GenerateBundleResources(plugins, sp)) + assert.Equal(t, generator.GenerateAppEnv(plugins, unset), generator.GenerateAppEnv(plugins, sp)) + assert.Empty(t, generator.GenerateUserAPIScopes(plugins, unset)) + assert.Empty(t, generator.GenerateUserAPIScopes(plugins, sp)) +} + +func TestAuthModeOBO(t *testing.T) { + plugins := authModePlugins() + cfg := authModeConfig(map[string]string{"sql-warehouse": generator.AuthModeOBO}) + + assert.NotContains(t, generator.GenerateBundleResources(plugins, cfg), "sql-warehouse") + assert.NotContains(t, generator.GenerateBundleVariables(plugins, cfg), "sql_warehouse_id") + assert.NotContains(t, generator.GenerateTargetVariables(plugins, cfg), "sql_warehouse_id") + assert.Equal(t, " - name: DATABRICKS_WAREHOUSE_ID\n value: wh1\n - name: GENIE_SPACE_ID\n valueFrom: genie-space", + generator.GenerateAppEnv(plugins, cfg)) + assert.Equal(t, " - sql\n - ai-gateway", generator.GenerateUserAPIScopes(plugins, cfg)) +} + +func TestAuthModeBoth(t *testing.T) { + plugins := authModePlugins() + cfg := authModeConfig(map[string]string{"sql-warehouse": generator.AuthModeBoth, "genie-space": generator.AuthModeOBO}) + + assert.Contains(t, generator.GenerateBundleResources(plugins, cfg), "- name: sql-warehouse") + assert.NotContains(t, generator.GenerateBundleResources(plugins, cfg), "genie-space") + assert.Contains(t, generator.GenerateAppEnv(plugins, cfg), "valueFrom: sql-warehouse") + assert.Equal(t, " - sql\n - genie\n - ai-gateway", generator.GenerateUserAPIScopes(plugins, cfg)) +} diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index b74f68bc699..37d5cfd7380 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -35,6 +35,13 @@ type Resource struct { Permission string `json:"permission"` // e.g., "CAN_USE" Fields map[string]ResourceField `json:"fields"` // field definitions with env var mappings + // Scope is the user_api_scope needed to use this resource on behalf of the user. + // Empty when the resource type cannot be used on behalf of the user. + Scope string `json:"scope,omitempty"` + + // AppOnly resources (e.g., secret, database) are always bound to the service principal. + AppOnly bool `json:"appOnly,omitempty"` + // PluginName is the machine name of the plugin (e.g., "lakebase"). // Set during resource collection. Not part of the JSON manifest. PluginName string `json:"-"` @@ -81,6 +88,9 @@ type Plugin struct { Resources Resources `json:"resources"` OnSetupMessage string `json:"onSetupMessage"` + // Scopes are user_api_scopes the plugin needs that are not tied to a resource (e.g., "ai-gateway"). + Scopes []string `json:"scopes,omitempty"` + // Stability is one of "beta", "ga", or empty. // Stored as a plain string so unknown future values round-trip unchanged. // See https://github.com/databricks/appkit/pull/264. diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 22042214334..dd118721c47 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -118,6 +118,7 @@ type CreateProjectConfig struct { Dependencies map[string]string // e.g., {"sql_warehouse_id": "abc123"} Deploy bool // Whether to deploy the app after creation RunMode RunMode // How to run the app after creation + AuthModes map[string]string // resource key -> auth mode chosen in the prompt } // App name constraints. From c457712c58229263e8760070afb089d94bd2f9e2 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 30 Sep 2026 12:42:15 +0200 Subject: [PATCH 2/7] docs: link PR in changelog fragment Co-authored-by: Isaac --- .nextchanges/cli/apps-init-auth-mode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/cli/apps-init-auth-mode.md b/.nextchanges/cli/apps-init-auth-mode.md index 609b3f42ad7..6044a71fb16 100644 --- a/.nextchanges/cli/apps-init-auth-mode.md +++ b/.nextchanges/cli/apps-init-auth-mode.md @@ -1 +1 @@ -* Added `--auth-mode` and `--set ..authMode=obo|sp|both` to `databricks apps init` so AppKit resources can be accessed on behalf of the user, by the service principal, or both. The default stays service principal. +* Added `--auth-mode` and `--set ..authMode=obo|sp|both` to `databricks apps init` so AppKit resources can be accessed on behalf of the user, by the service principal, or both. The default stays service principal. ([#6886](https://github.com/databricks/cli/pull/6886)) From ba6850967fcc33c1774ad53aa4e556ae84fe1a52 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 30 Sep 2026 12:48:44 +0200 Subject: [PATCH 3/7] feat: apply --auth-mode obo only to resources that support it Resources without a user scope and app-only resources stay on the service principal and are listed in an info line. An explicit --set authMode=obo on such a resource is still an error. Co-authored-by: Isaac --- acceptance/apps/init/auth-mode/output.txt | 156 +++++++++++++++++- acceptance/apps/init/auth-mode/script | 8 +- .../auth-mode/template/appkit.plugins.json | 1 + cmd/apps/init.go | 35 ++-- cmd/apps/init_test.go | 21 +-- 5 files changed, 190 insertions(+), 31 deletions(-) diff --git a/acceptance/apps/init/auth-mode/output.txt b/acceptance/apps/init/auth-mode/output.txt index a19f64d4cea..e2e5afdc70c 100644 --- a/acceptance/apps/init/auth-mode/output.txt +++ b/acceptance/apps/init/auth-mode/output.txt @@ -86,6 +86,7 @@ targets: === App-wide OBO, job kept on the service principal >>> [CLI] apps init --template ./template --skip-install --name app-obo --features analytics,genie,jobs --set analytics.sql-warehouse.id=wh1 --set genie.genie-space.id=g1 --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k --auth-mode obo --set jobs.job.authMode=sp +Note: these resources cannot be accessed on behalf of the user and use the service principal: secret ✔ Creating project ✔ Project created successfully! @@ -231,9 +232,160 @@ targets: secret_key: k secret_scope: s +=== App-wide OBO: resources without a scope stay on the service principal +>>> [CLI] apps init --template ./template --skip-install --name app-obo-lenient --features analytics,genie,jobs --set analytics.sql-warehouse.id=wh1 --set genie.genie-space.id=g1 --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k --auth-mode obo +Note: these resources cannot be accessed on behalf of the user and use the service principal: job, secret +✔ Creating project + +✔ Project created successfully! + + Location: [TEST_TMP_DIR]/app-obo-lenient + Files: 3 + +command: ['npm', 'run', 'start'] +env: + - name: DATABRICKS_WAREHOUSE_ID + value: wh1 + - name: DATABRICKS_GENIE_SPACE_ID + value: g1 + - name: DATABRICKS_JOB_ID + valueFrom: job + - name: SECRET_KEY + valueFrom: secret + - name: SECRET_SCOPE + valueFrom: secret +bundle: + name: app-obo-lenient + +variables: + job_id: + description: Job ID + secret_key: + description: Job token + secret_scope: + description: Job token + +resources: + apps: + app: + name: "app-obo-lenient" + description: "A Databricks App powered by AppKit" + source_code_path: ./ + + # Start the app automatically on deploy. + lifecycle: + started: true + user_api_scopes: + - sql + - genie + - ai-gateway + + # The resources which this app has access to. + resources: + - name: job + job: + id: ${var.job_id} + permission: CAN_MANAGE_RUN + - name: secret + secret: + scope: ${var.secret_scope} + key: ${var.secret_key} + permission: READ + +targets: + default: + default: true + workspace: + host: [DATABRICKS_URL] + + variables: + job_id: 42 + secret_key: k + secret_scope: s + +=== Analytics OBO, genie space on the service principal: plugin scopes still emitted +>>> [CLI] apps init --template ./template --skip-install --name app-genie-sp --features analytics,genie,jobs --set analytics.sql-warehouse.id=wh1 --set genie.genie-space.id=g1 --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k --set analytics.sql-warehouse.authMode=obo --set genie.genie-space.authMode=sp +✔ Creating project + +✔ Project created successfully! + + Location: [TEST_TMP_DIR]/app-genie-sp + Files: 3 + +command: ['npm', 'run', 'start'] +env: + - name: DATABRICKS_WAREHOUSE_ID + value: wh1 + - name: DATABRICKS_GENIE_SPACE_ID + valueFrom: genie-space + - name: DATABRICKS_JOB_ID + valueFrom: job + - name: SECRET_KEY + valueFrom: secret + - name: SECRET_SCOPE + valueFrom: secret +bundle: + name: app-genie-sp + +variables: + genie_space_id: + description: Genie Space ID + genie_space_name: + description: Genie Space + job_id: + description: Job ID + secret_key: + description: Job token + secret_scope: + description: Job token + +resources: + apps: + app: + name: "app-genie-sp" + description: "A Databricks App powered by AppKit" + source_code_path: ./ + + # Start the app automatically on deploy. + lifecycle: + started: true + user_api_scopes: + - sql + - ai-gateway + - genie + + # The resources which this app has access to. + resources: + - name: genie-space + genie_space: + name: ${var.genie_space_name} + space_id: ${var.genie_space_id} + permission: CAN_RUN + - name: job + job: + id: ${var.job_id} + permission: CAN_MANAGE_RUN + - name: secret + secret: + scope: ${var.secret_scope} + key: ${var.secret_key} + permission: READ + +targets: + default: + default: true + workspace: + host: [DATABRICKS_URL] + + variables: + genie_space_id: g1 + job_id: 42 + secret_key: k + secret_scope: s + === Errors ->>> errcode [CLI] apps init --template ./template --skip-install --name app-err --features jobs --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k --auth-mode obo -Error: resource "job" of plugin "jobs" cannot be accessed on behalf of the user; use --set jobs.job.authMode=sp +>>> errcode [CLI] apps init --template ./template --skip-install --name app-err --features jobs --set jobs.job.authMode=obo +Error: resource "job" of plugin "jobs" cannot be accessed on behalf of the user; use jobs.job.authMode=sp Exit code: 1 diff --git a/acceptance/apps/init/auth-mode/script b/acceptance/apps/init/auth-mode/script index a99cbdb572a..579cc2e2301 100644 --- a/acceptance/apps/init/auth-mode/script +++ b/acceptance/apps/init/auth-mode/script @@ -17,7 +17,13 @@ init app-obo --auth-mode obo --set jobs.job.authMode=sp title "Mixed: warehouse both, genie OBO" init app-mixed --set analytics.sql-warehouse.authMode=both --set genie.genie-space.authMode=obo +title "App-wide OBO: resources without a scope stay on the service principal" +init app-obo-lenient --auth-mode obo + +title "Analytics OBO, genie space on the service principal: plugin scopes still emitted" +init app-genie-sp --set analytics.sql-warehouse.authMode=obo --set genie.genie-space.authMode=sp + title "Errors" -trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --set jobs.job.id=42 --set jobs.secret.scope=s --set jobs.secret.key=k --auth-mode obo +trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --set jobs.job.authMode=obo trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --set jobs.secret.authMode=sp trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --auth-mode both diff --git a/acceptance/apps/init/auth-mode/template/appkit.plugins.json b/acceptance/apps/init/auth-mode/template/appkit.plugins.json index 3e7899ea703..0dfa00ca91d 100644 --- a/acceptance/apps/init/auth-mode/template/appkit.plugins.json +++ b/acceptance/apps/init/auth-mode/template/appkit.plugins.json @@ -27,6 +27,7 @@ "displayName": "Genie", "description": "Genie spaces", "package": "@databricks/appkit", + "scopes": ["genie"], "resources": { "required": [ { diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 47d713273c2..1be22b167f3 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -209,7 +209,7 @@ Environment variables: cmd.Flags().BoolVar(&deploy, "deploy", false, "Deploy the app after creation") cmd.Flags().StringVar(&run, "run", "", "Run the app after creation (none, dev, dev-remote)") cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip confirmation prompts for optional resources. Optional resources are only configured when their values are provided via --set.") - cmd.Flags().StringVar(&authMode, "auth-mode", "", "Default resource auth mode: obo (on behalf of the user) or sp (service principal). Prompts if not provided in interactive mode, otherwise sp.") + cmd.Flags().StringVar(&authMode, "auth-mode", "", "Default resource auth mode: obo (on behalf of the user) or sp (service principal). With obo, resources that cannot be accessed on behalf of the user use sp. Prompts if not provided in interactive mode, otherwise sp.") cmd.Flags().BoolVar(&skipInstall, "skip-install", false, "Skip installing project dependencies (e.g. npm install / uv sync). Cannot be combined with --run.") return cmd @@ -345,29 +345,28 @@ func parseSetAuthModes(setValues []string, m *manifest.Manifest) (map[string]str // resolveAuthModes returns the auth mode of each resource. Precedence, highest first: // --set authMode, the prompted choice, the --auth-mode default, then sp. -// App-only resources and resources without an entry resolve to sp. -func resolveAuthModes(resources []manifest.Resource, setModes, promptedModes map[string]string, defaultMode string) (map[string]string, error) { - modes := make(map[string]string) +// setModes and promptedModes only hold modes their resources support. The --auth-mode +// default applies only to resources that can be accessed on behalf of the user; the keys +// of the other resources it does not apply to are returned as keptSP. +func resolveAuthModes(resources []manifest.Resource, setModes, promptedModes map[string]string, defaultMode string) (modes map[string]string, keptSP []string) { + modes = make(map[string]string) for _, r := range resources { - if r.AppOnly { - continue - } mode, ok := setModes[r.Key()] if !ok { mode, ok = promptedModes[r.Key()] } - if !ok { + if !ok && defaultMode == generator.AuthModeOBO { + if r.AppOnly || r.Scope == "" { + keptSP = append(keptSP, r.Key()) + continue + } mode = defaultMode } - if mode == "" || mode == generator.AuthModeSP { - continue - } - if r.Scope == "" { - return nil, fmt.Errorf("resource %q of plugin %q cannot be accessed on behalf of the user; use --set %s.%s.%s=sp", r.Key(), r.PluginName, r.PluginName, r.Key(), authModeField) + if mode == generator.AuthModeOBO || mode == generator.AuthModeBoth { + modes[r.Key()] = mode } - modes[r.Key()] = mode } - return modes, nil + return modes, keptSP } // pluginHasResourceField checks whether a plugin declares a resource with the given key and field name. @@ -1471,11 +1470,11 @@ func runCreate(ctx context.Context, opts createOptions) error { // Always include mandatory plugins regardless of user selection or flags. selectedPlugins = appendUnique(selectedPlugins, m.GetMandatoryPluginNames()...) - authModes, err := resolveAuthModes( + authModes, keptSP := resolveAuthModes( append(m.CollectResources(selectedPlugins), m.CollectOptionalResources(selectedPlugins)...), setAuthModes, promptedAuthModes, opts.authMode) - if err != nil { - return err + if len(keptSP) > 0 { + cmdio.LogString(ctx, "Note: these resources cannot be accessed on behalf of the user and use the service principal: "+strings.Join(keptSP, ", ")) } // Warn when --features adds plugins that the pre-rendered template diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 9f9179904d0..16385f6c097 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -1521,22 +1521,23 @@ func TestResolveAuthModes(t *testing.T) { m := authModeManifest() resources := m.CollectResources([]string{"analytics", "jobs"}) - got, err := resolveAuthModes(resources, nil, nil, "") - require.NoError(t, err) + got, keptSP := resolveAuthModes(resources, nil, nil, "") assert.Empty(t, got) + assert.Empty(t, keptSP) - got, err = resolveAuthModes(resources, map[string]string{"job": "sp"}, nil, "obo") - require.NoError(t, err) + // The --auth-mode obo default skips resources without a scope and app-only resources. + got, keptSP = resolveAuthModes(resources, nil, nil, "obo") assert.Equal(t, map[string]string{"sql-warehouse": "obo"}, got) + assert.Equal(t, []string{"secret", "job"}, keptSP) - _, err = resolveAuthModes(resources, nil, nil, "obo") - assert.ErrorContains(t, err, "use --set jobs.job.authMode=sp") + // An explicit --set wins and is not reported as kept on sp. + got, keptSP = resolveAuthModes(resources, map[string]string{"job": "sp", "sql-warehouse": "sp"}, nil, "obo") + assert.Empty(t, got) + assert.Equal(t, []string{"secret"}, keptSP) - got, err = resolveAuthModes(resources, map[string]string{"sql-warehouse": "both"}, map[string]string{"sql-warehouse": "obo"}, "") - require.NoError(t, err) + got, _ = resolveAuthModes(resources, map[string]string{"sql-warehouse": "both"}, map[string]string{"sql-warehouse": "obo"}, "") assert.Equal(t, map[string]string{"sql-warehouse": "both"}, got) - got, err = resolveAuthModes(resources, nil, map[string]string{"sql-warehouse": "obo"}, "") - require.NoError(t, err) + got, _ = resolveAuthModes(resources, nil, map[string]string{"sql-warehouse": "obo"}, "") assert.Equal(t, map[string]string{"sql-warehouse": "obo"}, got) } From 64fc354772496649d809529ec512cdd5a0d04d92 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 30 Sep 2026 12:50:46 +0200 Subject: [PATCH 4/7] feat: check auth mode consistency in apps validate Resources that app.yaml injects as a literal value without a binding in databricks.yml are treated as accessed on behalf of the user. They must not be app-only, and their scope must be in user_api_scopes. Co-authored-by: Isaac --- acceptance/apps/init/auth-mode/output.txt | 15 +++ acceptance/apps/init/auth-mode/script | 8 ++ cmd/apps/validate.go | 7 ++ libs/apps/validation/authmode.go | 107 ++++++++++++++++++++++ libs/apps/validation/authmode_test.go | 89 ++++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 libs/apps/validation/authmode.go create mode 100644 libs/apps/validation/authmode_test.go diff --git a/acceptance/apps/init/auth-mode/output.txt b/acceptance/apps/init/auth-mode/output.txt index e2e5afdc70c..f03bb342363 100644 --- a/acceptance/apps/init/auth-mode/output.txt +++ b/acceptance/apps/init/auth-mode/output.txt @@ -398,3 +398,18 @@ Exit code: 1 Error: invalid --auth-mode value: "both" (must be obo or sp; use --set ..authMode=both for a single resource) Exit code: 1 + +=== Validate: OBO resource whose scope was removed from user_api_scopes +>>> [CLI] apps init --template ./template --skip-install --name app-val --features analytics --set analytics.sql-warehouse.id=wh1 --set analytics.sql-warehouse.authMode=obo +✔ Creating project + +✔ Project created successfully! + + Location: [TEST_TMP_DIR]/app-val + Files: 3 + + +>>> errcode [CLI] apps validate --path app-val +Error: resource "sql-warehouse" is accessed on behalf of the user but its scope "sql" is missing from user_api_scopes in databricks.yml + +Exit code: 1 diff --git a/acceptance/apps/init/auth-mode/script b/acceptance/apps/init/auth-mode/script index 579cc2e2301..94e8a4eb1b0 100644 --- a/acceptance/apps/init/auth-mode/script +++ b/acceptance/apps/init/auth-mode/script @@ -27,3 +27,11 @@ title "Errors" trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --set jobs.job.authMode=obo trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --set jobs.secret.authMode=sp trace errcode $CLI apps init --template ./template --skip-install --name app-err --features jobs --auth-mode both + +title "Validate: OBO resource whose scope was removed from user_api_scopes" +trace $CLI apps init --template ./template --skip-install --name app-val --features analytics \ + --set analytics.sql-warehouse.id=wh1 --set analytics.sql-warehouse.authMode=obo +grep -v -- '- sql$' app-val/databricks.yml > app-val/databricks.yml.new +mv app-val/databricks.yml.new app-val/databricks.yml +trace errcode $CLI apps validate --path app-val +rm -rf app-val diff --git a/cmd/apps/validate.go b/cmd/apps/validate.go index 34340192798..bbccc6bd1af 100644 --- a/cmd/apps/validate.go +++ b/cmd/apps/validate.go @@ -19,6 +19,9 @@ func newValidateCmd() *cobra.Command { This command detects the project type and runs the appropriate validation: - Node.js projects (package.json): runs npm install, build, typecheck, lint, and tests +For AppKit projects (appkit.plugins.json), it first checks that resources accessed on +behalf of the user are not app-only and that their scopes are in user_api_scopes. + Examples: # Validate the current directory databricks apps validate @@ -58,6 +61,10 @@ func runValidate(cmd *cobra.Command) error { SkipTests: skipTests, } + if err := validation.ValidateAuthModes(projectPath); err != nil { + return err + } + // Get validator for project type validator := validation.GetProjectValidator(projectPath) if validator == nil { diff --git a/libs/apps/validation/authmode.go b/libs/apps/validation/authmode.go new file mode 100644 index 00000000000..e22728fcfda --- /dev/null +++ b/libs/apps/validation/authmode.go @@ -0,0 +1,107 @@ +package validation + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + + "github.com/databricks/cli/libs/apps/manifest" + "go.yaml.in/yaml/v3" +) + +type appYAML struct { + Env []struct { + Name string `yaml:"name"` + Value *string `yaml:"value"` + } `yaml:"env"` +} + +type bundleYAML struct { + Resources struct { + Apps map[string]struct { + UserAPIScopes []string `yaml:"user_api_scopes"` + Resources []struct { + Name string `yaml:"name"` + } `yaml:"resources"` + } `yaml:"apps"` + } `yaml:"resources"` +} + +// ValidateAuthModes checks that resources accessed on behalf of the user are consistent +// with the manifest. A resource is treated as accessed on behalf of the user when app.yaml +// sets one of its env vars to a literal value and databricks.yml does not bind it. +// Such a resource must not be app-only, and its scope must be in user_api_scopes. +// Projects without appkit.plugins.json, app.yaml, or databricks.yml are not checked. +func ValidateAuthModes(projectPath string) error { + if !manifest.HasManifest(projectPath) { + return nil + } + m, err := manifest.Load(projectPath) + if err != nil { + return err + } + + var app appYAML + if ok, err := readYAML(filepath.Join(projectPath, "app.yaml"), &app); !ok { + return err + } + // ponytail: reads only the root databricks.yml, include files are not followed. + var bundle bundleYAML + if ok, err := readYAML(filepath.Join(projectPath, "databricks.yml"), &bundle); !ok { + return err + } + + bound := make(map[string]bool) + var scopes []string + for _, a := range bundle.Resources.Apps { + for _, r := range a.Resources { + bound[r.Name] = true + } + scopes = append(scopes, a.UserAPIScopes...) + } + + byEnv := make(map[string]manifest.Resource) + for _, p := range m.GetPlugins() { + for _, r := range append(p.Resources.Required, p.Resources.Optional...) { + for _, f := range r.Fields { + if f.Env != "" { + byEnv[f.Env] = r + } + } + } + } + + var errs []error + checked := make(map[string]bool) + for _, e := range app.Env { + r, ok := byEnv[e.Name] + if !ok || e.Value == nil || bound[r.Key()] || checked[r.Key()] { + continue + } + checked[r.Key()] = true + switch { + case r.AppOnly: + errs = append(errs, fmt.Errorf("resource %q must be bound to the app's service principal in databricks.yml, but app.yaml sets %s to a literal value", r.Key(), e.Name)) + case r.Scope != "" && !slices.Contains(scopes, r.Scope): + errs = append(errs, fmt.Errorf("resource %q is accessed on behalf of the user but its scope %q is missing from user_api_scopes in databricks.yml", r.Key(), r.Scope)) + } + } + return errors.Join(errs...) +} + +// readYAML parses path into v. Returns false with a nil error if the file does not exist. +func readYAML(path string, v any) (bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if err := yaml.Unmarshal(data, v); err != nil { + return false, fmt.Errorf("parse %s: %w", filepath.Base(path), err) + } + return true, nil +} diff --git a/libs/apps/validation/authmode_test.go b/libs/apps/validation/authmode_test.go new file mode 100644 index 00000000000..71553724f99 --- /dev/null +++ b/libs/apps/validation/authmode_test.go @@ -0,0 +1,89 @@ +package validation + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const authModeManifest = `{ + "plugins": { + "analytics": { + "resources": { + "required": [ + {"type": "sql_warehouse", "resourceKey": "sql-warehouse", "scope": "sql", "fields": {"id": {"env": "WAREHOUSE_ID"}}}, + {"type": "secret", "resourceKey": "secret", "appOnly": true, "fields": {"scope": {"env": "SECRET_SCOPE"}, "key": {"env": "SECRET_KEY"}}} + ] + } + } + } +}` + +func writeAuthModeProject(t *testing.T, appYAML, bundleYAML string) string { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "appkit.plugins.json"), []byte(authModeManifest), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "databricks.yml"), []byte(bundleYAML), 0o644)) + return dir +} + +func TestValidateAuthModes(t *testing.T) { + const boundSecret = ` +resources: + apps: + app: + resources: + - name: secret +` + tests := []struct { + name string + appYAML string + bundle string + wantErr string + }{ + { + name: "sp", + appYAML: "env:\n - name: WAREHOUSE_ID\n valueFrom: sql-warehouse\n", + bundle: "resources:\n apps:\n app:\n resources:\n - name: sql-warehouse\n", + }, + { + name: "obo with scope", + appYAML: "env:\n - name: WAREHOUSE_ID\n value: wh1\n", + bundle: "resources:\n apps:\n app:\n user_api_scopes:\n - sql\n", + }, + { + name: "bound with literal value is not obo", + appYAML: "env:\n - name: WAREHOUSE_ID\n value: wh1\n", + bundle: "resources:\n apps:\n app:\n resources:\n - name: sql-warehouse\n", + }, + { + name: "obo missing scope", + appYAML: "env:\n - name: WAREHOUSE_ID\n value: wh1\n", + bundle: boundSecret, + wantErr: `resource "sql-warehouse" is accessed on behalf of the user but its scope "sql" is missing from user_api_scopes in databricks.yml`, + }, + { + name: "app only not bound", + appYAML: "env:\n - name: SECRET_SCOPE\n value: s\n - name: SECRET_KEY\n value: k\n", + bundle: "resources:\n apps:\n app:\n user_api_scopes:\n - sql\n", + wantErr: `resource "secret" must be bound to the app's service principal in databricks.yml, but app.yaml sets SECRET_SCOPE to a literal value`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateAuthModes(writeAuthModeProject(t, tt.appYAML, tt.bundle)) + if tt.wantErr == "" { + assert.NoError(t, err) + return + } + assert.EqualError(t, err, tt.wantErr) + }) + } +} + +func TestValidateAuthModesWithoutManifest(t *testing.T) { + assert.NoError(t, ValidateAuthModes(t.TempDir())) +} From b297d079a09b434022e0642bd40f7a19ed6b6cf2 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 30 Sep 2026 14:31:13 +0200 Subject: [PATCH 5/7] fix: normalize Windows path separators in auth-mode acceptance test Co-authored-by: Isaac --- acceptance/apps/init/auth-mode/test.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/acceptance/apps/init/auth-mode/test.toml b/acceptance/apps/init/auth-mode/test.toml index 18b1a88417e..b7713e67a70 100644 --- a/acceptance/apps/init/auth-mode/test.toml +++ b/acceptance/apps/init/auth-mode/test.toml @@ -1 +1,6 @@ Cloud = false + +[[Repls]] +# Fix path with reverse slashes in the output for Windows. +Old = 'TEST_TMP_DIR]\\(app-[a-z-]+)' +New = 'TEST_TMP_DIR]/${1}' From 7338f1025d65d1213e184f2c8569f1377c55938b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 15:17:09 +0200 Subject: [PATCH 6/7] fix: harden apps init auth-mode resolution and validation - apps validate now follows root-level include globs so split-config bundles whose app resource or user_api_scopes live in an included file are no longer flagged with a false "scope missing" error. - Key AuthModes by a resource's type+key identity (Resource.AuthKey), matching resource de-duplication, so two resources sharing a key but differing in type no longer collide. Prevents an app-only resource from being forced on behalf of the user. Behavior is unchanged for the current manifest, where resource keys are unique per plugin. - The "kept on service principal" note no longer lists unconfigured optional resources that are absent from the generated project. Co-authored-by: Isaac --- cmd/apps/init.go | 33 ++++++++++++++++++------ cmd/apps/init_test.go | 25 ++++++++++++------- libs/apps/generator/generator.go | 6 ++--- libs/apps/generator/generator_test.go | 29 ++++++++++++++++++--- libs/apps/manifest/manifest.go | 8 ++++++ libs/apps/prompt/prompt.go | 2 +- libs/apps/validation/authmode.go | 36 +++++++++++++++++++++------ libs/apps/validation/authmode_test.go | 13 ++++++++++ 8 files changed, 121 insertions(+), 31 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 1be22b167f3..02d0e3c8c74 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -338,7 +338,7 @@ func parseSetAuthModes(setValues []string, m *manifest.Manifest) (map[string]str if value != generator.AuthModeSP && r.Scope == "" { return nil, fmt.Errorf("resource %q of plugin %q cannot be accessed on behalf of the user; use %s.%s.%s=sp", resourceKey, pluginName, pluginName, resourceKey, authModeField) } - modes[resourceKey] = value + modes[r.AuthKey()] = value } return modes, nil } @@ -351,9 +351,9 @@ func parseSetAuthModes(setValues []string, m *manifest.Manifest) (map[string]str func resolveAuthModes(resources []manifest.Resource, setModes, promptedModes map[string]string, defaultMode string) (modes map[string]string, keptSP []string) { modes = make(map[string]string) for _, r := range resources { - mode, ok := setModes[r.Key()] + mode, ok := setModes[r.AuthKey()] if !ok { - mode, ok = promptedModes[r.Key()] + mode, ok = promptedModes[r.AuthKey()] } if !ok && defaultMode == generator.AuthModeOBO { if r.AppOnly || r.Scope == "" { @@ -363,12 +363,23 @@ func resolveAuthModes(resources []manifest.Resource, setModes, promptedModes map mode = defaultMode } if mode == generator.AuthModeOBO || mode == generator.AuthModeBoth { - modes[r.Key()] = mode + modes[r.AuthKey()] = mode } } return modes, keptSP } +// resourceConfigured reports whether any of the resource's field values are set. +// Resource values are keyed "resourceKey.fieldName" (see parseSetValues). +func resourceConfigured(r manifest.Resource, values map[string]string) bool { + for _, fieldName := range r.FieldNames() { + if values[r.Key()+"."+fieldName] != "" { + return true + } + } + return false +} + // pluginHasResourceField checks whether a plugin declares a resource with the given key and field name. func pluginHasResourceField(p *manifest.Plugin, resourceKey, fieldName string) bool { for _, r := range append(p.Resources.Required, p.Resources.Optional...) { @@ -635,7 +646,7 @@ func promptForAuthModes(ctx context.Context, resources []manifest.Resource, them return nil, err } } - modes[r.Key()] = mode + modes[r.AuthKey()] = mode prompt.PrintAnswered(ctx, r.Alias, authModeLabel(mode)) } for _, r := range resources { @@ -1470,9 +1481,15 @@ func runCreate(ctx context.Context, opts createOptions) error { // Always include mandatory plugins regardless of user selection or flags. selectedPlugins = appendUnique(selectedPlugins, m.GetMandatoryPluginNames()...) - authModes, keptSP := resolveAuthModes( - append(m.CollectResources(selectedPlugins), m.CollectOptionalResources(selectedPlugins)...), - setAuthModes, promptedAuthModes, opts.authMode) + // Only optional resources that were actually configured end up in the project, + // so unconfigured ones must not be resolved or listed in the keptSP note. + authResources := m.CollectResources(selectedPlugins) + for _, r := range m.CollectOptionalResources(selectedPlugins) { + if resourceConfigured(r, resourceValues) { + authResources = append(authResources, r) + } + } + authModes, keptSP := resolveAuthModes(authResources, setAuthModes, promptedAuthModes, opts.authMode) if len(keptSP) > 0 { cmdio.LogString(ctx, "Note: these resources cannot be accessed on behalf of the user and use the service principal: "+strings.Join(keptSP, ", ")) } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 16385f6c097..7dc16505b93 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -1491,9 +1491,9 @@ func TestParseSetAuthModes(t *testing.T) { wantErr string }{ {name: "values ignored", setValues: []string{"analytics.sql-warehouse.id=wh"}, want: map[string]string{}}, - {name: "obo", setValues: []string{"analytics.sql-warehouse.authMode=obo"}, want: map[string]string{"sql-warehouse": "obo"}}, - {name: "both", setValues: []string{"analytics.sql-warehouse.authMode=both"}, want: map[string]string{"sql-warehouse": "both"}}, - {name: "sp without scope", setValues: []string{"jobs.job.authMode=sp"}, want: map[string]string{"job": "sp"}}, + {name: "obo", setValues: []string{"analytics.sql-warehouse.authMode=obo"}, want: map[string]string{"sql_warehouse:sql-warehouse": "obo"}}, + {name: "both", setValues: []string{"analytics.sql-warehouse.authMode=both"}, want: map[string]string{"sql_warehouse:sql-warehouse": "both"}}, + {name: "sp without scope", setValues: []string{"jobs.job.authMode=sp"}, want: map[string]string{"job:job": "sp"}}, {name: "invalid value", setValues: []string{"analytics.sql-warehouse.authMode=user"}, wantErr: "invalid auth mode"}, {name: "unknown resource", setValues: []string{"analytics.nope.authMode=obo"}, wantErr: "no resource with key"}, {name: "app only", setValues: []string{"analytics.secret.authMode=sp"}, wantErr: "always accessed by the app's service principal"}, @@ -1527,17 +1527,24 @@ func TestResolveAuthModes(t *testing.T) { // The --auth-mode obo default skips resources without a scope and app-only resources. got, keptSP = resolveAuthModes(resources, nil, nil, "obo") - assert.Equal(t, map[string]string{"sql-warehouse": "obo"}, got) + assert.Equal(t, map[string]string{"sql_warehouse:sql-warehouse": "obo"}, got) assert.Equal(t, []string{"secret", "job"}, keptSP) // An explicit --set wins and is not reported as kept on sp. - got, keptSP = resolveAuthModes(resources, map[string]string{"job": "sp", "sql-warehouse": "sp"}, nil, "obo") + got, keptSP = resolveAuthModes(resources, map[string]string{"job:job": "sp", "sql_warehouse:sql-warehouse": "sp"}, nil, "obo") assert.Empty(t, got) assert.Equal(t, []string{"secret"}, keptSP) - got, _ = resolveAuthModes(resources, map[string]string{"sql-warehouse": "both"}, map[string]string{"sql-warehouse": "obo"}, "") - assert.Equal(t, map[string]string{"sql-warehouse": "both"}, got) + got, _ = resolveAuthModes(resources, map[string]string{"sql_warehouse:sql-warehouse": "both"}, map[string]string{"sql_warehouse:sql-warehouse": "obo"}, "") + assert.Equal(t, map[string]string{"sql_warehouse:sql-warehouse": "both"}, got) - got, _ = resolveAuthModes(resources, nil, map[string]string{"sql-warehouse": "obo"}, "") - assert.Equal(t, map[string]string{"sql-warehouse": "obo"}, got) + got, _ = resolveAuthModes(resources, nil, map[string]string{"sql_warehouse:sql-warehouse": "obo"}, "") + assert.Equal(t, map[string]string{"sql_warehouse:sql-warehouse": "obo"}, got) +} + +func TestResourceConfigured(t *testing.T) { + r := manifest.Resource{ResourceKey: "wh", Fields: map[string]manifest.ResourceField{"id": {}}} + assert.False(t, resourceConfigured(r, nil)) + assert.False(t, resourceConfigured(r, map[string]string{"other.id": "x"})) + assert.True(t, resourceConfigured(r, map[string]string{"wh.id": "x"})) } diff --git a/libs/apps/generator/generator.go b/libs/apps/generator/generator.go index 7c17fdb9c8e..65267b81bb2 100644 --- a/libs/apps/generator/generator.go +++ b/libs/apps/generator/generator.go @@ -40,7 +40,7 @@ type Config struct { // ResourceValues maps resource value keys to values. // Keys use "resource_key.field_name" format (e.g., "sql-warehouse.id" -> "abc123"). ResourceValues map[string]string - // AuthModes maps resource keys to their auth mode (AuthModeSP, AuthModeOBO, or AuthModeBoth). + // AuthModes maps a resource's AuthKey to its auth mode (AuthModeSP, AuthModeOBO, or AuthModeBoth). // Resources without an entry use AuthModeSP. AuthModes map[string]string } @@ -57,12 +57,12 @@ const ( // isOBOOnly returns true if the resource is accessed only on behalf of the user (not bound to the service principal). func isOBOOnly(r manifest.Resource, cfg Config) bool { - return cfg.AuthModes[r.Key()] == AuthModeOBO + return cfg.AuthModes[r.AuthKey()] == AuthModeOBO } // needsScope returns true if the resource is accessed on behalf of the user. func needsScope(r manifest.Resource, cfg Config) bool { - mode := cfg.AuthModes[r.Key()] + mode := cfg.AuthModes[r.AuthKey()] return mode == AuthModeOBO || mode == AuthModeBoth } diff --git a/libs/apps/generator/generator_test.go b/libs/apps/generator/generator_test.go index 294b9873e51..286b7639d81 100644 --- a/libs/apps/generator/generator_test.go +++ b/libs/apps/generator/generator_test.go @@ -1178,7 +1178,7 @@ func authModeConfig(modes map[string]string) generator.Config { func TestAuthModeSPIsDefault(t *testing.T) { plugins := authModePlugins() - sp := authModeConfig(map[string]string{"sql-warehouse": generator.AuthModeSP}) + sp := authModeConfig(map[string]string{"sql_warehouse:sql-warehouse": generator.AuthModeSP}) unset := authModeConfig(nil) assert.Equal(t, generator.GenerateBundleResources(plugins, unset), generator.GenerateBundleResources(plugins, sp)) @@ -1189,7 +1189,7 @@ func TestAuthModeSPIsDefault(t *testing.T) { func TestAuthModeOBO(t *testing.T) { plugins := authModePlugins() - cfg := authModeConfig(map[string]string{"sql-warehouse": generator.AuthModeOBO}) + cfg := authModeConfig(map[string]string{"sql_warehouse:sql-warehouse": generator.AuthModeOBO}) assert.NotContains(t, generator.GenerateBundleResources(plugins, cfg), "sql-warehouse") assert.NotContains(t, generator.GenerateBundleVariables(plugins, cfg), "sql_warehouse_id") @@ -1201,10 +1201,33 @@ func TestAuthModeOBO(t *testing.T) { func TestAuthModeBoth(t *testing.T) { plugins := authModePlugins() - cfg := authModeConfig(map[string]string{"sql-warehouse": generator.AuthModeBoth, "genie-space": generator.AuthModeOBO}) + cfg := authModeConfig(map[string]string{"sql_warehouse:sql-warehouse": generator.AuthModeBoth, "genie_space:genie-space": generator.AuthModeOBO}) assert.Contains(t, generator.GenerateBundleResources(plugins, cfg), "- name: sql-warehouse") assert.NotContains(t, generator.GenerateBundleResources(plugins, cfg), "genie-space") assert.Contains(t, generator.GenerateAppEnv(plugins, cfg), "valueFrom: sql-warehouse") assert.Equal(t, " - sql\n - genie\n - ai-gateway", generator.GenerateUserAPIScopes(plugins, cfg)) } + +func TestAuthModeKeyDoesNotCollideAcrossTypes(t *testing.T) { + // Two resources share the resourceKey "data" but differ in type, so they must + // not share an auth mode: making the warehouse OBO must leave the app-only + // secret bound to the service principal and must not emit its empty scope. + plugins := []manifest.Plugin{ + {Name: "a", Resources: manifest.Resources{Required: []manifest.Resource{ + {Type: "sql_warehouse", ResourceKey: "data", Scope: "sql", Fields: map[string]manifest.ResourceField{"id": {Env: "WAREHOUSE_ID"}}}, + }}}, + {Name: "b", Resources: manifest.Resources{Required: []manifest.Resource{ + {Type: "secret", ResourceKey: "data", AppOnly: true, Fields: map[string]manifest.ResourceField{"token": {Env: "TOKEN"}}}, + }}}, + } + cfg := generator.Config{ + ResourceValues: map[string]string{"data.id": "wh1", "data.token": "s"}, + AuthModes: map[string]string{"sql_warehouse:data": generator.AuthModeOBO}, + } + + env := generator.GenerateAppEnv(plugins, cfg) + assert.Contains(t, env, " - name: WAREHOUSE_ID\n value: wh1") // warehouse: OBO literal + assert.Contains(t, env, " - name: TOKEN\n valueFrom: data") // secret: still SP-bound + assert.Equal(t, " - sql", generator.GenerateUserAPIScopes(plugins, cfg)) +} diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index 37d5cfd7380..1539b76fd64 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -56,6 +56,14 @@ func (r Resource) Key() string { return r.ResourceKey } +// AuthKey identifies a resource for auth-mode lookups using the same type+key +// identity as resource de-duplication (CollectResources, generator.DedupeResources). +// Two resources that collapse to one in the generated bundle therefore share an +// auth mode, while two resources with the same key but different types do not collide. +func (r Resource) AuthKey() string { + return r.Type + ":" + r.Key() +} + // VarPrefix returns the variable name prefix derived from the resource key. // Hyphens are replaced with underscores for YAML variable name compatibility. func (r Resource) VarPrefix() string { diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index dd118721c47..60071afd4fa 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -118,7 +118,7 @@ type CreateProjectConfig struct { Dependencies map[string]string // e.g., {"sql_warehouse_id": "abc123"} Deploy bool // Whether to deploy the app after creation RunMode RunMode // How to run the app after creation - AuthModes map[string]string // resource key -> auth mode chosen in the prompt + AuthModes map[string]string // resource AuthKey -> auth mode chosen in the prompt } // App name constraints. diff --git a/libs/apps/validation/authmode.go b/libs/apps/validation/authmode.go index e22728fcfda..9c723310007 100644 --- a/libs/apps/validation/authmode.go +++ b/libs/apps/validation/authmode.go @@ -19,6 +19,7 @@ type appYAML struct { } type bundleYAML struct { + Include []string `yaml:"include"` Resources struct { Apps map[string]struct { UserAPIScopes []string `yaml:"user_api_scopes"` @@ -47,19 +48,40 @@ func ValidateAuthModes(projectPath string) error { if ok, err := readYAML(filepath.Join(projectPath, "app.yaml"), &app); !ok { return err } - // ponytail: reads only the root databricks.yml, include files are not followed. - var bundle bundleYAML - if ok, err := readYAML(filepath.Join(projectPath, "databricks.yml"), &bundle); !ok { + var root bundleYAML + if ok, err := readYAML(filepath.Join(projectPath, "databricks.yml"), &root); !ok { return err } + // Follow the root-level include globs so split-config bundles, where the app + // resource and user_api_scopes live in separate files, are seen too. + bundles := []bundleYAML{root} + for _, pattern := range root.Include { + matches, err := filepath.Glob(filepath.Join(projectPath, pattern)) + if err != nil { + return fmt.Errorf("invalid include pattern %q: %w", pattern, err) + } + for _, path := range matches { + var inc bundleYAML + ok, err := readYAML(path, &inc) + if err != nil { + return err + } + if ok { + bundles = append(bundles, inc) + } + } + } + bound := make(map[string]bool) var scopes []string - for _, a := range bundle.Resources.Apps { - for _, r := range a.Resources { - bound[r.Name] = true + for _, b := range bundles { + for _, a := range b.Resources.Apps { + for _, r := range a.Resources { + bound[r.Name] = true + } + scopes = append(scopes, a.UserAPIScopes...) } - scopes = append(scopes, a.UserAPIScopes...) } byEnv := make(map[string]manifest.Resource) diff --git a/libs/apps/validation/authmode_test.go b/libs/apps/validation/authmode_test.go index 71553724f99..309de7dd9d4 100644 --- a/libs/apps/validation/authmode_test.go +++ b/libs/apps/validation/authmode_test.go @@ -87,3 +87,16 @@ resources: func TestValidateAuthModesWithoutManifest(t *testing.T) { assert.NoError(t, ValidateAuthModes(t.TempDir())) } + +func TestValidateAuthModesFollowsIncludes(t *testing.T) { + // An OBO resource whose user_api_scopes live in an included file must not be + // flagged as missing its scope. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "appkit.plugins.json"), []byte(authModeManifest), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("env:\n - name: WAREHOUSE_ID\n value: wh1\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "databricks.yml"), []byte("include:\n - resources/*.yml\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "resources", "app.yml"), []byte("resources:\n apps:\n app:\n user_api_scopes:\n - sql\n"), 0o644)) + + assert.NoError(t, ValidateAuthModes(dir)) +} From d0f764d076e3c8736c4514f6f94a2f257aa7ae9d Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 6 Oct 2026 14:11:16 +0200 Subject: [PATCH 7/7] acc: regenerate auth-mode out.test.toml after the engine matrix removal #6877 dropped the default DATABRICKS_BUNDLE_ENGINE matrix, so the test now runs direct-only. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- acceptance/apps/init/auth-mode/out.test.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/acceptance/apps/init/auth-mode/out.test.toml b/acceptance/apps/init/auth-mode/out.test.toml index 98ea5040486..18b1a88417e 100644 --- a/acceptance/apps/init/auth-mode/out.test.toml +++ b/acceptance/apps/init/auth-mode/out.test.toml @@ -1,2 +1 @@ Cloud = false -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"]