diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index 32762abeb..405ef5189 100644 --- a/docs/docs/api/appkit/Interface.PluginManifest.md +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -297,13 +297,21 @@ Omit.scaffolding ```ts optional scopes: ( + | "postgres" + | "sql" + | "model-serving" + | "genie" + | "files" + | "vector-search" + | "catalog.connections" | "ai-gateway" | "mcp.external" | "mcp.functions" | "workspace.workspace" | "catalog.catalogs:read" | "catalog.schemas:read" - | "catalog.tables:read")[]; + | "catalog.tables:read" + | "sql:restricted-query")[]; ``` #### Inherited from diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index fd726d41a..0cad35eff 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -5,11 +5,19 @@ "type": "object", "properties": { "scopes": { - "description": "Capability-only user_api_scopes with no resource ID.", + "description": "user_api_scopes the plugin always needs, whatever its resources are bound as: calls it makes on behalf of the user unconditionally, or capabilities with no resource ID.", "type": "array", "items": { "type": "string", "enum": [ + "sql", + "sql:restricted-query", + "genie", + "postgres", + "model-serving", + "files", + "vector-search", + "catalog.connections", "ai-gateway", "mcp.external", "mcp.functions", diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 435614e84..55ea25270 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -262,6 +262,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["READ", "WRITE", "MANAGE"], @@ -433,6 +486,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], @@ -604,6 +710,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_USE", "CAN_MANAGE"], @@ -775,6 +934,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"], @@ -946,6 +1158,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["READ_VOLUME", "WRITE_VOLUME"], @@ -1117,6 +1382,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["SELECT"], @@ -1288,6 +1606,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["EXECUTE"], @@ -1459,6 +1830,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["USE_CONNECTION"], @@ -1630,6 +2054,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -1801,6 +2278,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -1972,6 +2502,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": [ @@ -2148,6 +2731,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], @@ -2319,6 +2955,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_USE"], @@ -2490,14 +3179,67 @@ ], "description": "Describes how the CLI discovers values for a resource field. 'kind' references a well-known Databricks resource kind whose command is owned by AppKit; 'cli' is the escape hatch carrying a free-form Databricks CLI command." }, - "origin": { - "type": "string", - "enum": ["user", "platform", "static", "cli"], - "description": "How the field value is determined. Computed during sync, not authored by plugin developers." - } + "origin": { + "type": "string", + "enum": ["user", "platform", "static", "cli"], + "description": "How the field value is determined. Computed during sync, not authored by plugin developers." + } + }, + "additionalProperties": false + } + }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." }, - "additionalProperties": false - } + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false }, "permission": { "type": "string", @@ -2670,6 +3412,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], @@ -2841,6 +3636,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_USE", "CAN_MANAGE"], @@ -3012,6 +3860,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"], @@ -3183,6 +4084,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["READ_VOLUME", "WRITE_VOLUME"], @@ -3354,6 +4308,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["SELECT"], @@ -3525,6 +4532,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["EXECUTE"], @@ -3696,6 +4756,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["USE_CONNECTION"], @@ -3867,6 +4980,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -4038,6 +5204,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -4209,6 +5428,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": [ @@ -4385,6 +5657,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], @@ -4556,6 +5881,59 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_USE"], @@ -4580,6 +5958,31 @@ "required": ["required", "optional"], "additionalProperties": false, "description": "Databricks resource requirements for this plugin" + }, + "scopes": { + "description": "user_api_scopes the plugin always needs, copied from the plugin manifest. Omitted when empty.", + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "sql", + "sql:restricted-query", + "genie", + "postgres", + "model-serving", + "files", + "vector-search", + "catalog.connections", + "ai-gateway", + "mcp.external", + "mcp.functions", + "workspace.workspace", + "catalog.catalogs:read", + "catalog.schemas:read", + "catalog.tables:read" + ] + } } }, "required": [ diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 351d3988e..66dfd0b79 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -130,6 +130,15 @@ export class GeniePlugin extends Plugin implements ToolProvider { return this.config.spaces?.[alias] ?? null; } + /** + * Every route runs on behalf of the requesting user, whatever auth mode the + * genie_space resource is bound with. That is why manifest.json declares the + * plugin-level `scopes: ["genie"]`: the user token always needs the genie + * scope, even when the space is bound to the service principal. If these + * routes ever follow the resource's auth mode (as analytics does with + * `.obo.sql`), drop that plugin-level scope and let the resource-level scope + * from SCOPE_BY_TYPE cover it. + */ injectRoutes(router: IAppRouter) { this.route(router, { name: "sendMessage", diff --git a/packages/appkit/src/plugins/genie/manifest.json b/packages/appkit/src/plugins/genie/manifest.json index f62ba6116..bc42be21c 100644 --- a/packages/appkit/src/plugins/genie/manifest.json +++ b/packages/appkit/src/plugins/genie/manifest.json @@ -3,6 +3,7 @@ "name": "genie", "displayName": "Genie Plugin", "description": "AI/BI Genie space integration for natural language data queries", + "scopes": ["genie"], "resources": { "required": [ { diff --git a/packages/appkit/src/plugins/serving/manifest.json b/packages/appkit/src/plugins/serving/manifest.json index 3605e5104..4fcfd2f4a 100644 --- a/packages/appkit/src/plugins/serving/manifest.json +++ b/packages/appkit/src/plugins/serving/manifest.json @@ -4,6 +4,7 @@ "displayName": "Model Serving Plugin (deprecated)", "description": "DEPRECATED: use the agents plugin instead. Authenticated proxy to Databricks Model Serving endpoints", "deprecated": true, + "scopes": ["model-serving"], "resources": { "required": [ { diff --git a/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts new file mode 100644 index 000000000..0feb1a389 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts @@ -0,0 +1,181 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { scanForPlugins, scanPluginsDir } from "./sync"; + +const probe = { + name: "probe", + displayName: "Probe", + description: "Execution capability probe", + scopes: ["ai-gateway"], + resources: { + required: [ + { + type: "sql_warehouse", + alias: "Warehouse", + resourceKey: "sql-warehouse", + description: "OBO-capable resource", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, + ], + optional: [ + { + type: "secret", + alias: "Secret", + resourceKey: "secret", + description: "App-only resource", + permission: "READ", + fields: { scope: { env: "SECRET_SCOPE" }, key: { env: "SECRET_KEY" } }, + }, + { + type: "job", + alias: "Job", + resourceKey: "job", + description: "SP-only resource with no scope", + permission: "CAN_MANAGE_RUN", + fields: { id: { env: "DATABRICKS_JOB_ID" } }, + }, + ], + }, +}; + +const plain = { ...probe, name: "plain", scopes: undefined }; + +function writeManifest(dir: string, manifest: object) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest)); +} + +describe("sync execution capabilities", () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-sync-")); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const paths = { + "plugins dir (loadPluginEntry)": async () => { + writeManifest(path.join(tmp, "plugins", "probe"), probe); + writeManifest(path.join(tmp, "plugins", "plain"), plain); + return scanPluginsDir(path.join(tmp, "plugins"), "@x/pkg", false); + }, + "node_modules scan (scanForPlugins)": async () => { + const pluginsDir = path.join( + tmp, + "node_modules", + "@x/pkg", + "dist", + "plugins", + ); + writeManifest(path.join(pluginsDir, "probe"), probe); + writeManifest(path.join(pluginsDir, "plain"), plain); + return scanForPlugins(tmp, ["@x/pkg"], false); + }, + }; + + it.each(Object.entries(paths))( + "%s bakes scope, appOnly, and scopes", + async (_, scan) => { + const plugins = await scan(); + const [warehouse] = plugins.probe.resources.required; + const [secret, job] = plugins.probe.resources.optional; + + expect(warehouse.scope).toBe("sql"); + expect(warehouse).not.toHaveProperty("appOnly"); + expect(secret.appOnly).toBe(true); + expect(secret).not.toHaveProperty("scope"); + expect(job).not.toHaveProperty("scope"); + expect(job).not.toHaveProperty("appOnly"); + expect(plugins.probe.scopes).toEqual(["ai-gateway"]); + expect(plugins.plain).not.toHaveProperty("scopes"); + + // DABs binding is baked per resource from DABS_BINDING_BY_TYPE. + expect(warehouse.binding).toEqual({ + yamlKey: "sql_warehouse", + varFields: [["id", "id"]], + }); + expect(secret.binding).toEqual({ + yamlKey: "secret", + varFields: [ + ["scope", "scope"], + ["key", "key"], + ], + }); + expect(job.binding).toEqual({ + yamlKey: "job", + varFields: [["id", "id"]], + }); + }, + ); + + it("core plugins declare the scopes they always use on behalf of the user", async () => { + const plugins = await scanPluginsDir( + path.resolve(__dirname, "../../../../../../appkit/src/plugins"), + "@databricks/appkit", + false, + ); + // genie and serving routes always run as the user, whatever the resource binding. + expect(plugins.genie.scopes).toEqual(["genie"]); + expect(plugins.serving.scopes).toEqual(["model-serving"]); + // These only act as the user when a resource or config opts in, so they stay unscoped. + for (const name of ["analytics", "files", "aiSearch", "agents"]) { + expect(plugins[name]).not.toHaveProperty("scopes"); + } + + // DABs binding is baked from the real core manifests, including the + // uc_securable static field for volumes. + const genieSpace = plugins.genie.resources.required.find( + (r: { type: string }) => r.type === "genie_space", + ); + expect(genieSpace?.binding).toEqual({ + yamlKey: "genie_space", + varFields: [ + ["name", "name"], + ["id", "space_id"], + ], + }); + const volume = plugins.files.resources.required.find( + (r: { type: string }) => r.type === "volume", + ); + expect(volume?.binding).toEqual({ + yamlKey: "uc_securable", + // volume declares `path`, not `id`. + varFields: [["path", "securable_full_name"]], + staticFields: [["securable_type", "VOLUME"]], + }); + }); + + it("fails sync when a binding references a field the resource does not declare", async () => { + const bad = { + name: "badbind", + displayName: "Bad binding", + description: "Binding references an undeclared field", + resources: { + required: [ + { + type: "sql_warehouse", + alias: "Warehouse", + resourceKey: "sql-warehouse", + description: "sql_warehouse binding expects field `id`", + permission: "CAN_USE", + // Declares `region`, not `id`, so the baked binding varField `id` + // would reference an unset variable. + fields: { region: { env: "DATABRICKS_REGION" } }, + }, + ], + optional: [], + }, + }; + writeManifest(path.join(tmp, "plugins", "badbind"), bad); + await expect( + scanPluginsDir(path.join(tmp, "plugins"), "@x/pkg", false), + ).rejects.toThrow(/binding references manifest field "id"/); + }); +}); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 4227e5e81..439107931 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -6,6 +6,10 @@ import type { SgNode } from "@ast-grep/napi"; import { Command } from "commander"; import { + APP_ONLY_RESOURCE_TYPES, + DABS_BINDING_BY_TYPE, + type ResourceBinding, + SCOPE_BY_TYPE, TEMPLATE_SCAFFOLDING, templateFieldEntrySchema, } from "../../../../schemas/manifest"; @@ -80,32 +84,105 @@ async function loadPluginEntry( const manifest = validateManifestWithSchema(parsed, resolved.path); if (!manifest || manifest.hidden) return null; - return [ - manifest.name, - { - name: manifest.name, - displayName: manifest.displayName, - description: manifest.description, - package: pkg, - resources: manifest.resources, - ...(manifest.onSetupMessage && { - onSetupMessage: manifest.onSetupMessage, - }), - // Narrowing on `!== "ga"` removes "ga"; the truthy check - // removes `undefined`. What's left is the non-GA tier set, - // which TypeScript already knows is assignable to TemplatePlugin's - // `stability` field — so no cast is needed and adding a future - // tier (e.g. "alpha") flows through type-correctly. - ...(manifest.stability && - manifest.stability !== "ga" && { - stability: manifest.stability, + return [manifest.name, toTemplatePlugin(manifest, pkg)]; +} + +type ManifestResource = PluginManifest["resources"]["required"][number]; + +/** + * A binding spec references a manifest field the resource does not declare. + * Distinct class so discovery can re-throw it instead of swallowing it as a + * generic "failed to load manifest" warning. + */ +export class BindingFieldError extends Error {} + +/** Bake the type-level execution facts into the resource so the CLI reads plain data. */ +function withExecutionCapabilities( + resource: ManifestResource, + pluginName: string, +) { + const scope = Object.hasOwn(SCOPE_BY_TYPE, resource.type) + ? SCOPE_BY_TYPE[resource.type as keyof typeof SCOPE_BY_TYPE] + : undefined; + const binding: ResourceBinding | undefined = Object.hasOwn( + DABS_BINDING_BY_TYPE, + resource.type, + ) + ? DABS_BINDING_BY_TYPE[resource.type as keyof typeof DABS_BINDING_BY_TYPE] + : undefined; + if (binding) { + // Guard: every binding manifestField must be a declared field on the + // resource, otherwise the generator emits ${var._} against a + // variable nothing sets. This is the appkit analog of the CLI SDK anchor. + const declared = new Set(Object.keys(resource.fields ?? {})); + for (const [manifestField] of binding.varFields) { + if (!declared.has(manifestField)) { + throw new BindingFieldError( + `Plugin "${pluginName}" resource "${resource.resourceKey}" (${resource.type}): binding references manifest field "${manifestField}", which the resource does not declare. Declared fields: ${[...declared].join(", ") || "(none)"}. Fix DABS_BINDING_BY_TYPE or the resource fields.`, + ); + } + } + } + return { + ...resource, + ...(scope && { scope }), + ...(APP_ONLY_RESOURCE_TYPES.has(resource.type) && { + appOnly: true as const, + }), + ...(binding && { + // Materialize mutable tuples so the baked entry matches the template + // schema and never shares a reference with the source const table. + binding: { + yamlKey: binding.yamlKey, + varFields: binding.varFields.map( + (pair) => [pair[0], pair[1]] as [string, string], + ), + ...(binding.staticFields && { + staticFields: binding.staticFields.map( + (pair) => [pair[0], pair[1]] as [string, string], + ), }), - ...(manifest.deprecated && { deprecated: manifest.deprecated }), - ...(manifest.scaffolding && { - scaffolding: manifest.scaffolding, - }), + }, + }), + }; +} + +/** Build a TemplatePlugin entry. Every discovery path goes through here. */ +function toTemplatePlugin( + manifest: PluginManifest, + pkg: string, +): TemplatePlugin { + return { + name: manifest.name, + displayName: manifest.displayName, + description: manifest.description, + package: pkg, + resources: { + required: manifest.resources.required.map((resource) => + withExecutionCapabilities(resource, manifest.name), + ), + optional: manifest.resources.optional.map((resource) => + withExecutionCapabilities(resource, manifest.name), + ), }, - ]; + ...(manifest.scopes?.length && { scopes: manifest.scopes }), + ...(manifest.onSetupMessage && { + onSetupMessage: manifest.onSetupMessage, + }), + // Narrowing on `!== "ga"` removes "ga"; the truthy check + // removes `undefined`. What's left is the non-GA tier set, + // which TypeScript already knows is assignable to TemplatePlugin's + // `stability` field — so no cast is needed and adding a future + // tier (e.g. "alpha") flows through type-correctly. + ...(manifest.stability && + manifest.stability !== "ga" && { + stability: manifest.stability, + }), + ...(manifest.deprecated && { deprecated: manifest.deprecated }), + ...(manifest.scaffolding && { + scaffolding: manifest.scaffolding, + }), + }; } /** @@ -424,24 +501,7 @@ async function scanForPlugins( ); for (const manifest of manifests) { if (manifest.hidden) continue; - plugins[manifest.name] = { - name: manifest.name, - displayName: manifest.displayName, - description: manifest.description, - package: packageName, - resources: manifest.resources, - ...(manifest.onSetupMessage && { - onSetupMessage: manifest.onSetupMessage, - }), - ...(manifest.stability && - manifest.stability !== "ga" && { - stability: manifest.stability, - }), - ...(manifest.deprecated && { deprecated: manifest.deprecated }), - ...(manifest.scaffolding && { - scaffolding: manifest.scaffolding, - }), - } satisfies TemplatePlugin; + plugins[manifest.name] = toTemplatePlugin(manifest, packageName); } } @@ -532,6 +592,9 @@ async function scanPluginsDir( const pluginEntry = await loadPluginEntry(resolved, pkg, allowJsManifest); if (pluginEntry) plugins[pluginEntry[0]] = pluginEntry[1]; } catch (error) { + // A binding/field misconfig is a developer error, not a flaky manifest + // load; fail hard instead of warning and dropping the plugin. + if (error instanceof BindingFieldError) throw error; console.warn( `Warning: Failed to load manifest at ${resolved.path}:`, error instanceof Error ? error.message : error, @@ -879,11 +942,13 @@ async function runPluginsSync(options: { writeManifest(outputPath, { plugins }, options); } -/** Exported for testing: path boundary check, AST parsing, trust checks. */ +/** Exported for testing: path boundary check, AST parsing, trust checks, discovery paths. */ export { isWithinDirectory, parseImports, parsePluginUsages, + scanForPlugins, + scanPluginsDir, shouldAllowJsManifestForPackage, }; diff --git a/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts b/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts new file mode 100644 index 000000000..db1c16b15 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const template = fs.readFileSync( + path.resolve(__dirname, "../../../../../../../template/databricks.yml.tmpl"), + "utf-8", +); + +// Old CLIs never set .bundle.userApiScopes, so this branch must stay byte-for-byte. +const LEGACY_BLOCK = `{{- 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, genie, files, model-serving + # user_api_scopes: + # - sql +{{- end}}`; + +describe("template databricks.yml user_api_scopes", () => { + it("renders generator-provided scopes when set", () => { + expect(template).toContain( + "{{- if .bundle.userApiScopes}}\n user_api_scopes:\n{{.bundle.userApiScopes}}\n", + ); + }); + + it("falls back to the unchanged plugin-presence block", () => { + expect(template).toContain(`{{.bundle.userApiScopes}}\n${LEGACY_BLOCK}`); + }); +}); diff --git a/packages/shared/src/cli/commands/registry/add.test.ts b/packages/shared/src/cli/commands/registry/add.test.ts index 0143ca447..0822357bf 100644 --- a/packages/shared/src/cli/commands/registry/add.test.ts +++ b/packages/shared/src/cli/commands/registry/add.test.ts @@ -236,14 +236,14 @@ describe("scopesForResources", () => { resourceRow("volume"), ]); expect(Object.fromEntries(scopes)).toEqual({ - genie_space: "dashboards.genie", - serving_endpoint: "serving.serving-endpoints", - volume: "files.files", + genie_space: "genie", + serving_endpoint: "model-serving", + volume: "files", }); }); it("returns empty for resources that need no scope", () => { - expect(scopesForResources([resourceRow("sql_warehouse")]).size).toBe(0); + expect(scopesForResources([resourceRow("job")]).size).toBe(0); }); it("de-dupes repeated types", () => { diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index e6fc52946..6956bcf47 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -6,6 +6,7 @@ import process from "node:process"; import { Command } from "commander"; import pc from "picocolors"; +import { SCOPE_BY_TYPE } from "../../../schemas/manifest"; import { detectPackageManager } from "../../package-manager"; import { fetchRegistryItem, @@ -682,22 +683,18 @@ async function runAdd(refs: string[], opts: AddOptions): Promise { * extension). Warn when an added plugin's resource type is known to need one, * so the user adds it before deploy. */ -/** Resource types known to require a user_api_scope, and the scope each needs. */ -export const SCOPE_BY_RESOURCE_TYPE: Record = { - genie_space: "dashboards.genie", - serving_endpoint: "serving.serving-endpoints", - // volumes/files-backed access uses files.files - volume: "files.files", -}; - /** Returns the user_api_scopes implied by a set of resource rows (deduped). */ export function scopesForResources( rows: ResourceRequirementRow[], ): Map { const needed = new Map(); for (const row of rows) { - const scope = SCOPE_BY_RESOURCE_TYPE[row.type]; - if (scope) needed.set(row.type, scope); + if (Object.hasOwn(SCOPE_BY_TYPE, row.type)) { + needed.set( + row.type, + SCOPE_BY_TYPE[row.type as keyof typeof SCOPE_BY_TYPE], + ); + } } return needed; } diff --git a/packages/shared/src/schemas/manifest.test.ts b/packages/shared/src/schemas/manifest.test.ts index 13f2939f8..b6d9ce9e7 100644 --- a/packages/shared/src/schemas/manifest.test.ts +++ b/packages/shared/src/schemas/manifest.test.ts @@ -37,8 +37,16 @@ describe("manifest execution capabilities", () => { ).toEqual([]); }); - test("accepts every capability-only scope without resource IDs", () => { + test("accepts every user_api_scope as an authored plugin scope", () => { const scopes = [ + "sql", + "sql:restricted-query", + "genie", + "postgres", + "model-serving", + "files", + "vector-search", + "catalog.connections", "ai-gateway", "mcp.external", "mcp.functions", @@ -53,14 +61,6 @@ describe("manifest execution capabilities", () => { }); test.each([ - "sql", - "sql:restricted-query", - "postgres", - "genie", - "model-serving", - "files", - "vector-search", - "catalog.connections", "mlflow", "jobs", "apps", @@ -68,7 +68,7 @@ describe("manifest execution capabilities", () => { "files.files", "serving.serving-endpoints", "unknown", - ])("rejects %s as an authored capability-only scope", (scope) => { + ])("rejects %s as an authored plugin scope", (scope) => { expect( pluginManifestSchema.safeParse({ ...manifest, scopes: [scope] }).success, ).toBe(false); diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index af97f0521..1263d4023 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -72,6 +72,101 @@ export const APP_ONLY_RESOURCE_TYPES: ReadonlySet = new Set([ "postgres", ]); +/** + * How a resource type binds in `databricks.yml` as a DABs app resource. Owned + * here so the CLI consumes it as data instead of hardcoding a per-type map. + * + * - `yamlKey`: the DABs YAML key under the resource entry (e.g. `sql_warehouse`, + * `uc_securable`). + * - `varFields`: `[manifestField, dabsField]` pairs. Each becomes a + * `${var._}` reference written to `dabsField`. + * - `staticFields`: `[dabsField, value]` constant pairs (e.g. + * `securable_type` = `VOLUME`). + * + * Permission is not here; it stays the per-resource `permission` field. + */ +export interface ResourceBinding { + readonly yamlKey: string; + readonly varFields: ReadonlyArray; + readonly staticFields?: ReadonlyArray; +} + +/** + * DABs binding spec per resource type. Faithful port of the CLI's + * `appResourceSpecs`. App-only types still bind (as the service principal). The + * `app` type is intentionally absent: bundles do not yet support it as an app + * resource, matching the commented-out CLI entry. + * + * TODO(sdk-migration): anchor these yamlKeys to the Apps SDK once the modular migration + * (analytics-migration-sdk / #562) adds @databricks/sdk-apps: + * 1. add @databricks/sdk-apps; re-export AppResource via packages/shared/src/workspace-client/modular.ts + * (direct @databricks/sdk-* imports are banned by the repo lint rule) + * 2. the new AppResource is a $case union: kinds = NonNullable["$case"] + * (camelCase: sqlWarehouse | servingEndpoint | genieSpace | ucSecurable | ...) + * 3. map camelCase $case -> snake_case yamlKey and assert every table entry is covered + * (the new SDK models postgres/experiment/app, so the old skew exceptions are not needed) + */ +export const DABS_BINDING_BY_TYPE = { + sql_warehouse: { yamlKey: "sql_warehouse", varFields: [["id", "id"]] }, + job: { yamlKey: "job", varFields: [["id", "id"]] }, + serving_endpoint: { + yamlKey: "serving_endpoint", + varFields: [["name", "name"]], + }, + experiment: { + yamlKey: "experiment", + varFields: [["experimentId", "experiment_id"]], + }, + secret: { + yamlKey: "secret", + varFields: [ + ["scope", "scope"], + ["key", "key"], + ], + }, + database: { + yamlKey: "database", + varFields: [ + ["instance_name", "instance_name"], + ["database_name", "database_name"], + ], + }, + postgres: { + yamlKey: "postgres", + varFields: [ + ["branch", "branch"], + ["database", "database"], + ], + }, + genie_space: { + yamlKey: "genie_space", + varFields: [ + ["name", "name"], + ["id", "space_id"], + ], + }, + volume: { + yamlKey: "uc_securable", + varFields: [["path", "securable_full_name"]], + staticFields: [["securable_type", "VOLUME"]], + }, + uc_function: { + yamlKey: "uc_securable", + varFields: [["id", "securable_full_name"]], + staticFields: [["securable_type", "FUNCTION"]], + }, + uc_connection: { + yamlKey: "uc_securable", + varFields: [["id", "securable_full_name"]], + staticFields: [["securable_type", "CONNECTION"]], + }, + vector_search_index: { + yamlKey: "uc_securable", + varFields: [["id", "securable_full_name"]], + staticFields: [["securable_type", "TABLE"]], + }, +} as const satisfies Partial>; + /** Capabilities that need a user_api_scope but have no resource ID. */ export const capabilityScopeSchema = z.enum([ "ai-gateway", @@ -83,7 +178,25 @@ export const capabilityScopeSchema = z.enum([ "catalog.tables:read", ]); -export type CapabilityScope = z.infer; +/** + * Every Apps user_api_scope (short names; the long forms such as + * `dashboards.genie` are deprecated aliases). A plugin declares one in its + * `scopes` when it always calls on behalf of the user, or when the capability + * has no resource ID. + */ +export const userApiScopeSchema = z.enum([ + "sql", + "sql:restricted-query", + "genie", + "postgres", + "model-serving", + "files", + "vector-search", + "catalog.connections", + ...capabilityScopeSchema.options, +]); + +export type UserApiScope = z.infer; export const secretPermissionSchema = z .enum(["READ", "WRITE", "MANAGE"]) @@ -702,9 +815,11 @@ export const pluginScaffoldingRulesSchema = z export const pluginManifestSchema = z .object({ scopes: z - .array(capabilityScopeSchema) + .array(userApiScopeSchema) .optional() - .describe("Capability-only user_api_scopes with no resource ID."), + .describe( + "user_api_scopes the plugin always needs, whatever its resources are bound as: calls it makes on behalf of the user unconditionally, or capabilities with no resource ID.", + ), $schema: z .string() .optional() @@ -882,6 +997,40 @@ const templateResourceRequirementBaseShape = { }) .optional() .describe("Map of field name to field entry with computed origin."), + scope: z + .string() + .min(1) + .optional() + .describe( + "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + ), + appOnly: z + .literal(true) + .optional() + .describe( + "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + ), + binding: z + .object({ + yamlKey: z + .string() + .min(1) + .describe("DABs YAML key under the resource entry."), + varFields: z + .array(z.tuple([z.string(), z.string()])) + .describe( + "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField.", + ), + staticFields: z + .array(z.tuple([z.string(), z.string()])) + .optional() + .describe("[dabsField, value] constant pairs."), + }) + .strict() + .optional() + .describe( + "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + ), }; function makeTemplateResourceVariant< @@ -1021,6 +1170,13 @@ export const templatePluginSchema = z }) .strict() .describe("Databricks resource requirements for this plugin"), + scopes: z + .array(userApiScopeSchema) + .min(1) + .optional() + .describe( + "user_api_scopes the plugin always needs, copied from the plugin manifest. Omitted when empty.", + ), }) .strict() .describe("Plugin manifest with package source information"); diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 61a3e0b12..db53b96b7 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -22,6 +22,16 @@ "description": "Default LLM serving endpoint name", "origin": "user" } + }, + "scope": "model-serving", + "binding": { + "yamlKey": "serving_endpoint", + "varFields": [ + [ + "name", + "name" + ] + ] } }, { @@ -36,6 +46,15 @@ "description": "MLflow experiment id traces are logged to", "origin": "user" } + }, + "binding": { + "yamlKey": "experiment", + "varFields": [ + [ + "experimentId", + "experiment_id" + ] + ] } }, { @@ -55,6 +74,22 @@ }, "origin": "user" } + }, + "scope": "files", + "binding": { + "yamlKey": "uc_securable", + "varFields": [ + [ + "path", + "securable_full_name" + ] + ], + "staticFields": [ + [ + "securable_type", + "VOLUME" + ] + ] } } ] @@ -80,6 +115,22 @@ "description": "Three-level UC name of the default index (catalog.schema.index_name)", "origin": "user" } + }, + "scope": "vector-search", + "binding": { + "yamlKey": "uc_securable", + "varFields": [ + [ + "id", + "securable_full_name" + ] + ], + "staticFields": [ + [ + "securable_type", + "TABLE" + ] + ] } } ], @@ -119,6 +170,16 @@ }, "origin": "user" } + }, + "scope": "sql", + "binding": { + "yamlKey": "sql_warehouse", + "varFields": [ + [ + "id", + "id" + ] + ] } } ], @@ -159,6 +220,22 @@ }, "origin": "user" } + }, + "scope": "files", + "binding": { + "yamlKey": "uc_securable", + "varFields": [ + [ + "path", + "securable_full_name" + ] + ], + "staticFields": [ + [ + "securable_type", + "VOLUME" + ] + ] } } ], @@ -199,11 +276,28 @@ "description": "Genie Space display name", "origin": "user" } + }, + "scope": "genie", + "binding": { + "yamlKey": "genie_space", + "varFields": [ + [ + "name", + "name" + ], + [ + "id", + "space_id" + ] + ] } } ], "optional": [] }, + "scopes": [ + "genie" + ], "scaffolding": { "rules": { "must": [ @@ -231,6 +325,15 @@ "description": "Numeric Databricks job ID. Find it in the Jobs UI or via `databricks jobs list`.", "origin": "user" } + }, + "binding": { + "yamlKey": "job", + "varFields": [ + [ + "id", + "id" + ] + ] } } ], @@ -328,6 +431,20 @@ "value": "require", "origin": "platform" } + }, + "appOnly": true, + "binding": { + "yamlKey": "postgres", + "varFields": [ + [ + "branch", + "branch" + ], + [ + "database", + "database" + ] + ] } } ], @@ -372,11 +489,24 @@ "description": "Serving endpoint name", "origin": "user" } + }, + "scope": "model-serving", + "binding": { + "yamlKey": "serving_endpoint", + "varFields": [ + [ + "name", + "name" + ] + ] } } ], "optional": [] }, + "scopes": [ + "model-serving" + ], "deprecated": true } }, diff --git a/template/databricks.yml.tmpl b/template/databricks.yml.tmpl index a646a4376..d5b27e251 100644 --- a/template/databricks.yml.tmpl +++ b/template/databricks.yml.tmpl @@ -17,7 +17,16 @@ resources: lifecycle: started: true -{{- if or .plugins.genie .plugins.files .plugins.serving}} +{{- /* The `else if` plugin block below is a legacy fallback. It is still the + only scope source for CLIs older than per-resource auth mode (they never set + .bundle.userApiScopes) and for the new CLI in pure service-principal mode, + where a genie app still needs a user scope because genie routes always run + on behalf of the user. Remove it once the minimum supported CLI includes + auth mode AND the CLI emits plugin-level scopes in service-principal mode. */}} +{{- 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 @@ -29,7 +38,7 @@ resources: - serving.serving-endpoints {{- end}} {{- else}} - # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files, serving.serving-endpoints + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, genie, files, model-serving # user_api_scopes: # - sql {{- end}}